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
4 changes: 4 additions & 0 deletions dotnet/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
193 changes: 162 additions & 31 deletions dotnet/mcpb.Tests/CliPackFileValidationTests.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
using System.Text.Json;
using Xunit;
using System;
using System.IO;
using System.Text.Json;
using Mcpb.Json;
using Xunit;

namespace Mcpb.Tests;

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();
Expand All @@ -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<string> { "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<string> { "${__dirname}/server/index.js" } }
}
};
Name = "demo",
Description = "desc",
Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" },
Icon = "icon.png",
Screenshots = new List<string> { "shots/s1.png" },
Server = new Mcpb.Core.McpbManifestServer
{
Type = "node",
EntryPoint = "server/index.js",
McpConfig = new Mcpb.Core.McpServerConfigWithOverrides
{
Command = "node",
Args = new List<string> { "${__dirname}/server/index.js" },
},
},
};

[Fact]
public void Pack_MissingIcon_Fails()
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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()
{
Expand All @@ -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);
Expand All @@ -129,9 +248,12 @@ public void Pack_MissingIconsFile_Fails()
manifest.ManifestVersion = "0.3";
manifest.Icons = new List<Mcpb.Core.McpbManifestIcon>
{
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);
Expand All @@ -149,9 +271,12 @@ public void Pack_IconsFilePresent_Succeeds()
manifest.Screenshots = null; // Remove screenshots requirement for this test
manifest.Icons = new List<Mcpb.Core.McpbManifestIcon>
{
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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading