diff --git a/dotnet/CLI.md b/dotnet/CLI.md index 234e7ad..ff03fdc 100644 --- a/dotnet/CLI.md +++ b/dotnet/CLI.md @@ -166,6 +166,10 @@ Before launching the server or writing the archive, `mcpb pack` now validates th If any of these files are missing, packing fails immediately with an error like `Missing icon file: icon.png`. This happens before dynamic capability discovery so you get fast feedback on manifest inaccuracies. +On Windows, `.exe` entry points or path-like commands can be satisfied by [App Execution Aliases](https://learn.microsoft.com/windows/apps/desktop/modernize/desktop-to-uwp-extensions). When a referenced `.exe` is not present under your extension directory, the CLI automatically checks `%LOCALAPPDATA%\Microsoft\WindowsApps` (the folder where aliases surface). To point discovery/validation at custom alias locations—or to simulate aliases in CI—set `MCPB_WINDOWS_APP_ALIAS_DIRS` to a path-separated list of directories. + +When discovery launches your server it resolves the executable using the same logic, so an alias that passes validation is the exact binary that will be executed. + Commands (e.g. `node`, `python`) that are not path-like are not validated—they are treated as executables resolved by the environment. Examples: diff --git a/dotnet/mcpb.Tests/CliPackFileValidationTests.cs b/dotnet/mcpb.Tests/CliPackFileValidationTests.cs index 13d5f5a..9c13ca4 100644 --- a/dotnet/mcpb.Tests/CliPackFileValidationTests.cs +++ b/dotnet/mcpb.Tests/CliPackFileValidationTests.cs @@ -1,7 +1,8 @@ -using System.Text.Json; -using Xunit; +using System; using System.IO; +using System.Text.Json; using Mcpb.Json; +using Xunit; namespace Mcpb.Tests; @@ -9,12 +10,19 @@ public class CliPackFileValidationTests { private string CreateTempDir() { - var dir = Path.Combine(Path.GetTempPath(), "mcpb_cli_pack_files_" + Guid.NewGuid().ToString("N")); + var dir = Path.Combine( + Path.GetTempPath(), + "mcpb_cli_pack_files_" + Guid.NewGuid().ToString("N") + ); Directory.CreateDirectory(dir); Directory.CreateDirectory(Path.Combine(dir, "server")); return dir; } - private (int exitCode, string stdout, string stderr) InvokeCli(string workingDir, params string[] args) + + private (int exitCode, string stdout, string stderr) InvokeCli( + string workingDir, + params string[] args + ) { var root = Mcpb.Commands.CliRoot.Build(); var prev = Directory.GetCurrentDirectory(); @@ -26,23 +34,31 @@ private string CreateTempDir() var code = CommandRunner.Invoke(root, args, swOut, swErr); return (code, swOut.ToString(), swErr.ToString()); } - finally { Directory.SetCurrentDirectory(prev); } + finally + { + Directory.SetCurrentDirectory(prev); + } } - private Mcpb.Core.McpbManifest BaseManifest() => new Mcpb.Core.McpbManifest - { - Name = "demo", - Description = "desc", - Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, - Icon = "icon.png", - Screenshots = new List { "shots/s1.png" }, - Server = new Mcpb.Core.McpbManifestServer + private Mcpb.Core.McpbManifest BaseManifest() => + new Mcpb.Core.McpbManifest { - Type = "node", - EntryPoint = "server/index.js", - McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "node", Args = new List { "${__dirname}/server/index.js" } } - } - }; + Name = "demo", + Description = "desc", + Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, + Icon = "icon.png", + Screenshots = new List { "shots/s1.png" }, + Server = new Mcpb.Core.McpbManifestServer + { + Type = "node", + EntryPoint = "server/index.js", + McpConfig = new Mcpb.Core.McpServerConfigWithOverrides + { + Command = "node", + Args = new List { "${__dirname}/server/index.js" }, + }, + }, + }; [Fact] public void Pack_MissingIcon_Fails() @@ -52,7 +68,10 @@ public void Pack_MissingIcon_Fails() Directory.CreateDirectory(Path.Combine(dir, "shots")); File.WriteAllText(Path.Combine(dir, "shots", "s1.png"), "fake"); var manifest = BaseManifest(); - File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); var (code, _, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); Assert.NotEqual(0, code); Assert.Contains("Missing icon file", stderr); @@ -66,7 +85,10 @@ public void Pack_MissingEntryPoint_Fails() Directory.CreateDirectory(Path.Combine(dir, "shots")); File.WriteAllText(Path.Combine(dir, "shots", "s1.png"), "fake"); var manifest = BaseManifest(); - File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); var (code, _, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); Assert.NotEqual(0, code); Assert.Contains("Missing entry_point file", stderr); @@ -79,7 +101,10 @@ public void Pack_MissingScreenshot_Fails() File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); File.WriteAllText(Path.Combine(dir, "server", "index.js"), "// js"); var manifest = BaseManifest(); - File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); var (code, _, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); Assert.NotEqual(0, code); Assert.Contains("Missing screenshot file", stderr); @@ -96,12 +121,103 @@ public void Pack_PathLikeCommandMissing_Fails() var manifest = BaseManifest(); // Make command path-like to trigger validation manifest.Server.McpConfig.Command = "${__dirname}/server/missing.js"; - File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); var (code, _, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); Assert.NotEqual(0, code); Assert.Contains("Missing server.command file", stderr); } + [Fact] + public void Pack_CommandWindowsAlias_Succeeds() + { + var aliasDir = Path.Combine( + Path.GetTempPath(), + "mcpb_windows_alias_" + Guid.NewGuid().ToString("N") + ); + Directory.CreateDirectory(aliasDir); + var aliasName = "alias-command.exe"; + File.WriteAllText(Path.Combine(aliasDir, aliasName), "alias"); + var previousAliases = Environment.GetEnvironmentVariable("MCPB_WINDOWS_APP_ALIAS_DIRS"); + Environment.SetEnvironmentVariable("MCPB_WINDOWS_APP_ALIAS_DIRS", aliasDir); + try + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); + File.WriteAllText(Path.Combine(dir, "server", "index.js"), "// js"); + Directory.CreateDirectory(Path.Combine(dir, "shots")); + File.WriteAllText(Path.Combine(dir, "shots", "s1.png"), "fake"); + var manifest = BaseManifest(); + manifest.Server.McpConfig.Command = aliasName; + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); + Assert.Equal(0, code); + Assert.Contains("demo@", stdout); + Assert.DoesNotContain("Missing server.command", stderr); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_WINDOWS_APP_ALIAS_DIRS", previousAliases); + try + { + Directory.Delete(aliasDir, true); + } + catch + { + // Ignore cleanup failures in tests + } + } + } + + [Fact] + public void Pack_EntryPointWindowsAlias_Succeeds() + { + var aliasDir = Path.Combine( + Path.GetTempPath(), + "mcpb_windows_alias_" + Guid.NewGuid().ToString("N") + ); + Directory.CreateDirectory(aliasDir); + var aliasName = "alias-entry.exe"; + File.WriteAllText(Path.Combine(aliasDir, aliasName), "alias"); + var previousAliases = Environment.GetEnvironmentVariable("MCPB_WINDOWS_APP_ALIAS_DIRS"); + Environment.SetEnvironmentVariable("MCPB_WINDOWS_APP_ALIAS_DIRS", aliasDir); + try + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); + File.WriteAllText(Path.Combine(dir, "server", "index.js"), "// js"); + Directory.CreateDirectory(Path.Combine(dir, "shots")); + File.WriteAllText(Path.Combine(dir, "shots", "s1.png"), "fake"); + var manifest = BaseManifest(); + manifest.Server.EntryPoint = aliasName; + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); + Assert.Equal(0, code); + Assert.Contains("demo@", stdout); + Assert.DoesNotContain("Missing entry_point", stderr); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_WINDOWS_APP_ALIAS_DIRS", previousAliases); + try + { + Directory.Delete(aliasDir, true); + } + catch + { + // Ignore cleanup failures in tests + } + } + } + [Fact] public void Pack_AllFilesPresent_Succeeds() { @@ -112,7 +228,10 @@ public void Pack_AllFilesPresent_Succeeds() File.WriteAllText(Path.Combine(dir, "shots", "s1.png"), "fake"); var manifest = BaseManifest(); // Ensure command not path-like (node) so validation doesn't require it to exist as file - File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); Assert.Equal(0, code); Assert.Contains("demo@", stdout); @@ -129,9 +248,12 @@ public void Pack_MissingIconsFile_Fails() manifest.ManifestVersion = "0.3"; manifest.Icons = new List { - new() { Src = "icon-16.png", Size = "16x16" } + new() { Src = "icon-16.png", Size = "16x16" }, }; - File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); var (code, _, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); Assert.NotEqual(0, code); Assert.Contains("Missing icons[0] file", stderr); @@ -149,9 +271,12 @@ public void Pack_IconsFilePresent_Succeeds() manifest.Screenshots = null; // Remove screenshots requirement for this test manifest.Icons = new List { - new() { Src = "icon-16.png", Size = "16x16" } + new() { Src = "icon-16.png", Size = "16x16" }, }; - File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); Assert.True(code == 0, $"Pack failed with code {code}. Stderr: {stderr}"); Assert.Contains("demo@", stdout); @@ -168,9 +293,12 @@ public void Pack_MissingLocalizationResources_Fails() manifest.Localization = new Mcpb.Core.McpbManifestLocalization { Resources = "locales/${locale}/messages.json", - DefaultLocale = "en-US" + DefaultLocale = "en-US", }; - File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); var (code, _, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); Assert.NotEqual(0, code); Assert.Contains("Missing localization resources", stderr); @@ -190,9 +318,12 @@ public void Pack_LocalizationResourcesPresent_Succeeds() manifest.Localization = new Mcpb.Core.McpbManifestLocalization { Resources = "locales/${locale}/messages.json", - DefaultLocale = "en-US" + DefaultLocale = "en-US", }; - File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); Assert.True(code == 0, $"Pack failed with code {code}. Stderr: {stderr}"); Assert.Contains("demo@", stdout); diff --git a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs index 3496566..c40276d 100644 --- a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs +++ b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs @@ -18,10 +18,15 @@ namespace Mcpb.Commands; internal static class ManifestCommandHelpers { + private const string WindowsAppAliasDirectoriesEnvVar = "MCPB_WINDOWS_APP_ALIAS_DIRS"; private static readonly TimeSpan DiscoveryTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan DiscoveryInitializationTimeout = TimeSpan.FromSeconds(15); - private static readonly IReadOnlyDictionary> EmptyUserConfigOverrides = - new Dictionary>(StringComparer.Ordinal); + private static readonly IReadOnlyDictionary< + string, + IReadOnlyList + > EmptyUserConfigOverrides = new Dictionary>( + StringComparer.Ordinal + ); private static readonly Regex UserConfigTokenRegex = new( "\\$\\{user_config\\.([^}]+)\\}", RegexOptions.IgnoreCase | RegexOptions.Compiled @@ -198,7 +203,7 @@ string Resolve(string rel) return Path.Combine(baseDir, normalized.Replace('/', Path.DirectorySeparatorChar)); } - void CheckFile(string? relativePath, string category) + void CheckFile(string? relativePath, string category, bool allowAliasResolution = false) { if (string.IsNullOrWhiteSpace(relativePath)) return; @@ -209,7 +214,20 @@ void CheckFile(string? relativePath, string category) verboseLog?.Invoke($"Ensuring {category} file exists: {relativePath} -> {resolved}"); if (!File.Exists(resolved)) { - errors.Add($"Missing {category} file: {relativePath}"); + var trimmed = relativePath.Trim(); + if ( + allowAliasResolution + && TryResolveWindowsAppExecutionAlias(trimmed, out var aliasPath) + ) + { + verboseLog?.Invoke( + $"Resolved {category} '{trimmed}' via Windows app execution alias: {aliasPath}" + ); + } + else + { + errors.Add($"Missing {category} file: {relativePath}"); + } } } @@ -221,7 +239,7 @@ void CheckFile(string? relativePath, string category) if (!string.IsNullOrWhiteSpace(manifest.Server.EntryPoint)) { verboseLog?.Invoke($"Checking server entry point {manifest.Server.EntryPoint}"); - CheckFile(manifest.Server.EntryPoint, "entry_point"); + CheckFile(manifest.Server.EntryPoint, "entry_point", allowAliasResolution: true); } var command = manifest.Server.McpConfig?.Command; @@ -229,15 +247,7 @@ void CheckFile(string? relativePath, string category) { var cmd = command!; verboseLog?.Invoke($"Resolving server command {cmd}"); - bool pathLike = - cmd.Contains('/') - || cmd.Contains('\\') - || cmd.StartsWith("${__dirname}", StringComparison.OrdinalIgnoreCase) - || cmd.StartsWith("./") - || cmd.StartsWith("..") - || cmd.EndsWith(".js", StringComparison.OrdinalIgnoreCase) - || cmd.EndsWith(".py", StringComparison.OrdinalIgnoreCase) - || cmd.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); + bool pathLike = IsCommandPathLike(cmd); if (pathLike) { var expanded = ExpandToken(cmd, baseDir); @@ -250,7 +260,16 @@ void CheckFile(string? relativePath, string category) verboseLog?.Invoke($"Ensuring server command file exists: {resolved}"); if (!File.Exists(resolved)) { - errors.Add($"Missing server.command file: {command}"); + if (TryResolveWindowsAppExecutionAlias(cmd, out var aliasPath)) + { + verboseLog?.Invoke( + $"Resolved server command '{cmd}' via Windows app execution alias: {aliasPath}" + ); + } + else + { + errors.Add($"Missing server.command file: {command}"); + } } } } @@ -309,6 +328,117 @@ void CheckFile(string? relativePath, string category) return errors; } + private static bool TryResolveWindowsAppExecutionAlias( + string? candidate, + out string resolvedPath + ) + { + resolvedPath = string.Empty; + if (string.IsNullOrWhiteSpace(candidate)) + return false; + + var trimmed = candidate.Trim(); + if (trimmed.Length == 0) + return false; + + if ( + trimmed.IndexOf('/') >= 0 + || trimmed.IndexOf('\\') >= 0 + || !trimmed.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ) + { + return false; + } + + foreach (var directory in EnumerateWindowsAppAliasDirectories()) + { + try + { + var path = Path.Combine(directory, trimmed); + if (File.Exists(path)) + { + resolvedPath = path; + return true; + } + } + catch + { + // Ignore issues accessing alias directories + } + } + + return false; + } + + private static IEnumerable EnumerateWindowsAppAliasDirectories() + { + var overrideValue = Environment.GetEnvironmentVariable(WindowsAppAliasDirectoriesEnvVar); + var reported = new HashSet(StringComparer.OrdinalIgnoreCase); + + void Add(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return; + try + { + var full = Path.GetFullPath(path); + if (!Directory.Exists(full)) + return; + full = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (reported.Add(full)) + { + // nothing else to do + } + } + catch + { + // Ignore invalid directories + } + } + + if (!string.IsNullOrWhiteSpace(overrideValue)) + { + var splits = overrideValue.Split( + Path.PathSeparator, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ); + foreach (var part in splits) + { + Add(part); + } + } + else if (OperatingSystem.IsWindows()) + { + var localAppData = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData + ); + if (!string.IsNullOrWhiteSpace(localAppData)) + { + Add(Path.Combine(localAppData, "Microsoft", "WindowsApps")); + } + } + + foreach (var dir in reported) + { + yield return dir; + } + } + + private static bool IsCommandPathLike(string command) + { + if (string.IsNullOrWhiteSpace(command)) + return false; + var trimmed = command.Trim(); + return trimmed.Contains('/') + || trimmed.Contains('\\') + || trimmed.StartsWith("${__dirname}", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("./") + || trimmed.StartsWith("..") + || trimmed.EndsWith(".js", StringComparison.OrdinalIgnoreCase) + || trimmed.EndsWith(".py", StringComparison.OrdinalIgnoreCase) + || trimmed.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); + } + internal static List ValidateLocalizationCompleteness( McpbManifest manifest, string baseDir, @@ -595,6 +725,7 @@ internal static async Task DiscoverCapabilitiesAsync( var providedUserConfig = userConfigOverrides ?? EmptyUserConfigOverrides; EnsureRequiredUserConfigProvided(manifest, command, rawArgs, providedUserConfig); + var originalCommand = command; command = ExpandToken(command, dir, providedUserConfig); var args = new List(); foreach (var rawArg in rawArgs) @@ -611,6 +742,33 @@ internal static async Task DiscoverCapabilitiesAsync( for (int i = 0; i < args.Count; i++) args[i] = NormalizePathForPlatform(args[i]); + if (IsCommandPathLike(originalCommand)) + { + var resolved = command; + if (!Path.IsPathRooted(resolved)) + { + resolved = Path.Combine(dir, resolved); + } + + if (File.Exists(resolved)) + { + command = resolved; + } + else if (TryResolveWindowsAppExecutionAlias(originalCommand, out var aliasPath)) + { + logInfo?.Invoke( + $"Resolved server command '{originalCommand}' via Windows app execution alias: {aliasPath}" + ); + command = aliasPath; + } + else + { + throw new InvalidOperationException( + $"Unable to locate server.mcp_config.command executable: {originalCommand}" + ); + } + } + Dictionary? env = null; if (cfg.Env != null && cfg.Env.Count > 0) { diff --git a/dotnet/mcpb/mcpb.csproj b/dotnet/mcpb/mcpb.csproj index 656d4dc..91cd4a8 100644 --- a/dotnet/mcpb/mcpb.csproj +++ b/dotnet/mcpb/mcpb.csproj @@ -9,7 +9,7 @@ true mcpb Mcpb.Cli - 0.3.4 + 0.3.5 Alexander Sklar CLI tool for building MCP Bundles (.mcpb) MCP;MCPB;CLI;bundles;DXT;ModelContextProtocol