From bd4371e1e4c50a387d63fcdf1647749e01bfa9d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 21:40:01 +0000 Subject: [PATCH 1/8] Initial plan From 19e89adeff47c7d5c4cbea7ca3772736d2e5401a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:03:01 +0000 Subject: [PATCH 2/8] Add localization and icons support to .NET MCPB CLI (manifest 0.3) Co-authored-by: asklar <22989529+asklar@users.noreply.github.com> --- .../mcpb.Tests/CliPackFileValidationTests.cs | 79 ++++++++++ dotnet/mcpb.Tests/ManifestValidatorTests.cs | 136 ++++++++++++++++++ .../mcpb/Commands/ManifestCommandHelpers.cs | 30 ++++ dotnet/mcpb/Core/ManifestModels.cs | 15 ++ dotnet/mcpb/Core/ManifestValidator.cs | 29 ++++ dotnet/mcpb/Json/JsonContext.cs | 2 + 6 files changed, 291 insertions(+) diff --git a/dotnet/mcpb.Tests/CliPackFileValidationTests.cs b/dotnet/mcpb.Tests/CliPackFileValidationTests.cs index e6d3750..9fc66ed 100644 --- a/dotnet/mcpb.Tests/CliPackFileValidationTests.cs +++ b/dotnet/mcpb.Tests/CliPackFileValidationTests.cs @@ -118,4 +118,83 @@ public void Pack_AllFilesPresent_Succeeds() Assert.Contains("demo@", stdout); Assert.DoesNotContain("Missing", stderr); } + + [Fact] + public void Pack_MissingIconsFile_Fails() + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); + File.WriteAllText(Path.Combine(dir, "server", "index.js"), "// js"); + var manifest = BaseManifest(); + manifest.ManifestVersion = "0.3"; + manifest.Icons = new List + { + new() { Src = "icon-16.png", Sizes = "16x16" } + }; + 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); + } + + [Fact] + public void Pack_IconsFilePresent_Succeeds() + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); + File.WriteAllText(Path.Combine(dir, "icon-16.png"), "fake16"); + File.WriteAllText(Path.Combine(dir, "server", "index.js"), "// js"); + var manifest = BaseManifest(); + manifest.ManifestVersion = "0.3"; + manifest.Screenshots = null; // Remove screenshots requirement for this test + manifest.Icons = new List + { + new() { Src = "icon-16.png", Sizes = "16x16" } + }; + 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); + } + + [Fact] + public void Pack_MissingLocalizationResources_Fails() + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); + File.WriteAllText(Path.Combine(dir, "server", "index.js"), "// js"); + var manifest = BaseManifest(); + manifest.ManifestVersion = "0.3"; + manifest.Localization = new Mcpb.Core.McpbManifestLocalization + { + Resources = "locales/${locale}/messages.json", + DefaultLocale = "en-US" + }; + 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); + } + + [Fact] + public void Pack_LocalizationResourcesPresent_Succeeds() + { + 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, "locales", "en-US")); + File.WriteAllText(Path.Combine(dir, "locales", "en-US", "messages.json"), "{}"); + var manifest = BaseManifest(); + manifest.ManifestVersion = "0.3"; + manifest.Screenshots = null; // Remove screenshots requirement for this test + manifest.Localization = new Mcpb.Core.McpbManifestLocalization + { + Resources = "locales/${locale}/messages.json", + DefaultLocale = "en-US" + }; + 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.Tests/ManifestValidatorTests.cs b/dotnet/mcpb.Tests/ManifestValidatorTests.cs index 91f0cde..54e5e2e 100644 --- a/dotnet/mcpb.Tests/ManifestValidatorTests.cs +++ b/dotnet/mcpb.Tests/ManifestValidatorTests.cs @@ -96,4 +96,140 @@ public void PromptMissingText_ProducesWarning() var warning = Assert.Single(issues, i => i.Path == "prompts[0].text"); Assert.Equal(ValidationSeverity.Warning, warning.Severity); } + + [Fact] + public void ValidLocalization_Passes() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Localization = new McpbManifestLocalization + { + Resources = "locales/${locale}/messages.json", + DefaultLocale = "en-US" + }; + var issues = ManifestValidator.Validate(m); + Assert.Empty(issues); + } + + [Fact] + public void LocalizationMissingResources_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Localization = new McpbManifestLocalization + { + Resources = "", + DefaultLocale = "en-US" + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "localization.resources"); + } + + [Fact] + public void LocalizationResourcesWithoutPlaceholder_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Localization = new McpbManifestLocalization + { + Resources = "locales/messages.json", + DefaultLocale = "en-US" + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "localization.resources" && i.Message.Contains("placeholder")); + } + + [Fact] + public void LocalizationMissingDefaultLocale_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Localization = new McpbManifestLocalization + { + Resources = "locales/${locale}/messages.json", + DefaultLocale = "" + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "localization.default_locale"); + } + + [Fact] + public void LocalizationInvalidDefaultLocale_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Localization = new McpbManifestLocalization + { + Resources = "locales/${locale}/messages.json", + DefaultLocale = "invalid locale" + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "localization.default_locale" && i.Message.Contains("BCP 47")); + } + + [Fact] + public void ValidIcons_Passes() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + new() { Src = "icon-16.png", Sizes = "16x16" }, + new() { Src = "icon-32.png", Sizes = "32x32", Theme = "light" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Empty(issues); + } + + [Fact] + public void IconMissingSrc_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + new() { Src = "", Sizes = "16x16" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "icons[0].src"); + } + + [Fact] + public void IconMissingSizes_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + new() { Src = "icon.png", Sizes = "" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "icons[0].sizes"); + } + + [Fact] + public void IconInvalidSizesFormat_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + new() { Src = "icon.png", Sizes = "16" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "icons[0].sizes" && i.Message.Contains("WIDTHxHEIGHT")); + } + + [Fact] + public void IconEmptyTheme_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + new() { Src = "icon.png", Sizes = "16x16", Theme = "" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "icons[0].theme" && i.Message.Contains("empty")); + } } diff --git a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs index 010f33a..44e88f1 100644 --- a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs +++ b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs @@ -147,6 +147,36 @@ void CheckFile(string? relativePath, string category) } } + if (manifest.Icons != null) + { + for (int i = 0; i < manifest.Icons.Count; i++) + { + var icon = manifest.Icons[i]; + if (!string.IsNullOrWhiteSpace(icon.Src)) + { + CheckFile(icon.Src, $"icons[{i}]"); + } + } + } + + if (manifest.Localization != null && !string.IsNullOrWhiteSpace(manifest.Localization.Resources)) + { + // Check if the localization resources path exists + // The resources path should contain a ${locale} placeholder, so we need to check with the default_locale + var resourcePath = manifest.Localization.Resources; + if (!string.IsNullOrWhiteSpace(manifest.Localization.DefaultLocale)) + { + var defaultLocalePath = resourcePath.Replace("${locale}", manifest.Localization.DefaultLocale, StringComparison.OrdinalIgnoreCase); + var resolved = Resolve(defaultLocalePath); + + // Check if it's a file or directory + if (!File.Exists(resolved) && !Directory.Exists(resolved)) + { + errors.Add($"Missing localization resources for default locale: {defaultLocalePath}"); + } + } + } + return errors; } diff --git a/dotnet/mcpb/Core/ManifestModels.cs b/dotnet/mcpb/Core/ManifestModels.cs index e4e8aa9..b27c89f 100644 --- a/dotnet/mcpb/Core/ManifestModels.cs +++ b/dotnet/mcpb/Core/ManifestModels.cs @@ -74,6 +74,19 @@ public class McpbUserConfigOption [JsonPropertyName("max")] public double? Max { get; set; } } +public class McpbManifestLocalization +{ + [JsonPropertyName("resources")] public string Resources { get; set; } = string.Empty; + [JsonPropertyName("default_locale")] public string DefaultLocale { get; set; } = string.Empty; +} + +public class McpbManifestIcon +{ + [JsonPropertyName("src")] public string Src { get; set; } = string.Empty; + [JsonPropertyName("sizes")] public string Sizes { get; set; } = string.Empty; + [JsonPropertyName("theme")] public string? Theme { get; set; } +} + public class McpbInitializeResult { [JsonPropertyName("protocolVersion")] public string? ProtocolVersion { get; set; } @@ -115,7 +128,9 @@ public class McpbManifest [JsonPropertyName("documentation")] public string? Documentation { get; set; } [JsonPropertyName("support")] public string? Support { get; set; } [JsonPropertyName("icon")] public string? Icon { get; set; } + [JsonPropertyName("icons")] public List? Icons { get; set; } [JsonPropertyName("screenshots")] public List? Screenshots { get; set; } + [JsonPropertyName("localization")] public McpbManifestLocalization? Localization { get; set; } [JsonPropertyName("server")] public McpbManifestServer Server { get; set; } = new(); [JsonPropertyName("tools")] public List? Tools { get; set; } [JsonPropertyName("tools_generated")] public bool? ToolsGenerated { get; set; } diff --git a/dotnet/mcpb/Core/ManifestValidator.cs b/dotnet/mcpb/Core/ManifestValidator.cs index 148fbb6..2f6cc81 100644 --- a/dotnet/mcpb/Core/ManifestValidator.cs +++ b/dotnet/mcpb/Core/ManifestValidator.cs @@ -117,6 +117,35 @@ void CheckUrl(string? url, string path) if (v.Min.HasValue && v.Max.HasValue && v.Min > v.Max) issues.Add(new($"user_config.{kv.Key}", "min cannot exceed max")); } + if (m.Localization != null) + { + if (string.IsNullOrWhiteSpace(m.Localization.Resources)) + issues.Add(new("localization.resources", "resources is required when localization is present")); + else if (!m.Localization.Resources.Contains("${locale}", StringComparison.OrdinalIgnoreCase)) + issues.Add(new("localization.resources", "resources must include a \"${locale}\" placeholder")); + + if (string.IsNullOrWhiteSpace(m.Localization.DefaultLocale)) + issues.Add(new("localization.default_locale", "default_locale is required when localization is present")); + else if (!Regex.IsMatch(m.Localization.DefaultLocale, "^[A-Za-z0-9]{2,8}(?:-[A-Za-z0-9]{1,8})*$")) + issues.Add(new("localization.default_locale", "default_locale must be a valid BCP 47 locale identifier")); + } + + if (m.Icons != null) + { + for (int i = 0; i < m.Icons.Count; i++) + { + var icon = m.Icons[i]; + if (string.IsNullOrWhiteSpace(icon.Src)) + issues.Add(new($"icons[{i}].src", "src is required")); + if (string.IsNullOrWhiteSpace(icon.Sizes)) + issues.Add(new($"icons[{i}].sizes", "sizes is required")); + else if (!Regex.IsMatch(icon.Sizes, "^\\d+x\\d+$")) + issues.Add(new($"icons[{i}].sizes", "sizes must be in the format \"WIDTHxHEIGHT\" (e.g., \"16x16\")")); + if (icon.Theme != null && string.IsNullOrWhiteSpace(icon.Theme)) + issues.Add(new($"icons[{i}].theme", "theme cannot be empty when provided")); + } + } + return issues; } diff --git a/dotnet/mcpb/Json/JsonContext.cs b/dotnet/mcpb/Json/JsonContext.cs index e3a53a9..2d58af2 100644 --- a/dotnet/mcpb/Json/JsonContext.cs +++ b/dotnet/mcpb/Json/JsonContext.cs @@ -13,6 +13,8 @@ namespace Mcpb.Json; [JsonSerializable(typeof(McpbStaticResponses))] [JsonSerializable(typeof(McpbInitializeResult))] [JsonSerializable(typeof(McpbToolsListResult))] +[JsonSerializable(typeof(McpbManifestLocalization))] +[JsonSerializable(typeof(McpbManifestIcon))] [JsonSerializable(typeof(System.Text.Json.JsonElement))] [JsonSerializable(typeof(int))] [JsonSerializable(typeof(long))] From ed170cad0719ddb2ae9ad52fd40451032ba97702 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:31:10 +0000 Subject: [PATCH 3/8] Make localization fields optional with defaults (resources: mcpb-resources/${locale}.json, default_locale: en-US) Co-authored-by: asklar <22989529+asklar@users.noreply.github.com> --- dotnet/mcpb.Tests/ManifestValidatorTests.cs | 20 +++++++--------- .../mcpb/Commands/ManifestCommandHelpers.cs | 24 +++++++++---------- dotnet/mcpb/Core/ManifestModels.cs | 4 ++-- dotnet/mcpb/Core/ManifestValidator.cs | 12 +++++----- 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/dotnet/mcpb.Tests/ManifestValidatorTests.cs b/dotnet/mcpb.Tests/ManifestValidatorTests.cs index 54e5e2e..3413975 100644 --- a/dotnet/mcpb.Tests/ManifestValidatorTests.cs +++ b/dotnet/mcpb.Tests/ManifestValidatorTests.cs @@ -112,17 +112,18 @@ public void ValidLocalization_Passes() } [Fact] - public void LocalizationMissingResources_Fails() + public void LocalizationWithDefaults_Passes() { var m = BaseManifest(); m.ManifestVersion = "0.3"; + // Resources and DefaultLocale are optional with defaults m.Localization = new McpbManifestLocalization { - Resources = "", - DefaultLocale = "en-US" + Resources = null, // defaults to "mcpb-resources/${locale}.json" + DefaultLocale = null // defaults to "en-US" }; var issues = ManifestValidator.Validate(m); - Assert.Contains(issues, i => i.Path == "localization.resources"); + Assert.Empty(issues); } [Fact] @@ -140,17 +141,14 @@ public void LocalizationResourcesWithoutPlaceholder_Fails() } [Fact] - public void LocalizationMissingDefaultLocale_Fails() + public void LocalizationEmptyObject_PassesWithDefaults() { var m = BaseManifest(); m.ManifestVersion = "0.3"; - m.Localization = new McpbManifestLocalization - { - Resources = "locales/${locale}/messages.json", - DefaultLocale = "" - }; + // Empty localization object should use defaults + m.Localization = new McpbManifestLocalization(); var issues = ManifestValidator.Validate(m); - Assert.Contains(issues, i => i.Path == "localization.default_locale"); + Assert.Empty(issues); } [Fact] diff --git a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs index 44e88f1..8276b7a 100644 --- a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs +++ b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs @@ -159,21 +159,21 @@ void CheckFile(string? relativePath, string category) } } - if (manifest.Localization != null && !string.IsNullOrWhiteSpace(manifest.Localization.Resources)) + if (manifest.Localization != null) { // Check if the localization resources path exists - // The resources path should contain a ${locale} placeholder, so we need to check with the default_locale - var resourcePath = manifest.Localization.Resources; - if (!string.IsNullOrWhiteSpace(manifest.Localization.DefaultLocale)) + // Resources defaults to "mcpb-resources/${locale}.json" if not specified + var resourcePath = manifest.Localization.Resources ?? "mcpb-resources/${locale}.json"; + // DefaultLocale defaults to "en-US" if not specified + var defaultLocale = manifest.Localization.DefaultLocale ?? "en-US"; + + var defaultLocalePath = resourcePath.Replace("${locale}", defaultLocale, StringComparison.OrdinalIgnoreCase); + var resolved = Resolve(defaultLocalePath); + + // Check if it's a file or directory + if (!File.Exists(resolved) && !Directory.Exists(resolved)) { - var defaultLocalePath = resourcePath.Replace("${locale}", manifest.Localization.DefaultLocale, StringComparison.OrdinalIgnoreCase); - var resolved = Resolve(defaultLocalePath); - - // Check if it's a file or directory - if (!File.Exists(resolved) && !Directory.Exists(resolved)) - { - errors.Add($"Missing localization resources for default locale: {defaultLocalePath}"); - } + errors.Add($"Missing localization resources for default locale: {defaultLocalePath}"); } } diff --git a/dotnet/mcpb/Core/ManifestModels.cs b/dotnet/mcpb/Core/ManifestModels.cs index b27c89f..a32ec93 100644 --- a/dotnet/mcpb/Core/ManifestModels.cs +++ b/dotnet/mcpb/Core/ManifestModels.cs @@ -76,8 +76,8 @@ public class McpbUserConfigOption public class McpbManifestLocalization { - [JsonPropertyName("resources")] public string Resources { get; set; } = string.Empty; - [JsonPropertyName("default_locale")] public string DefaultLocale { get; set; } = string.Empty; + [JsonPropertyName("resources")] public string? Resources { get; set; } + [JsonPropertyName("default_locale")] public string? DefaultLocale { get; set; } } public class McpbManifestIcon diff --git a/dotnet/mcpb/Core/ManifestValidator.cs b/dotnet/mcpb/Core/ManifestValidator.cs index 2f6cc81..cc49b63 100644 --- a/dotnet/mcpb/Core/ManifestValidator.cs +++ b/dotnet/mcpb/Core/ManifestValidator.cs @@ -119,14 +119,14 @@ void CheckUrl(string? url, string path) if (m.Localization != null) { - if (string.IsNullOrWhiteSpace(m.Localization.Resources)) - issues.Add(new("localization.resources", "resources is required when localization is present")); - else if (!m.Localization.Resources.Contains("${locale}", StringComparison.OrdinalIgnoreCase)) + // Resources is optional; default is "mcpb-resources/${locale}.json" + var resources = m.Localization.Resources ?? "mcpb-resources/${locale}.json"; + if (!resources.Contains("${locale}", StringComparison.OrdinalIgnoreCase)) issues.Add(new("localization.resources", "resources must include a \"${locale}\" placeholder")); - if (string.IsNullOrWhiteSpace(m.Localization.DefaultLocale)) - issues.Add(new("localization.default_locale", "default_locale is required when localization is present")); - else if (!Regex.IsMatch(m.Localization.DefaultLocale, "^[A-Za-z0-9]{2,8}(?:-[A-Za-z0-9]{1,8})*$")) + // DefaultLocale is optional; default is "en-US" + var defaultLocale = m.Localization.DefaultLocale ?? "en-US"; + if (!Regex.IsMatch(defaultLocale, "^[A-Za-z0-9]{2,8}(?:-[A-Za-z0-9]{1,8})*$")) issues.Add(new("localization.default_locale", "default_locale must be a valid BCP 47 locale identifier")); } From 8e2bc36121969396950e5adfc9b7b86e27f76833 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:58:46 +0000 Subject: [PATCH 4/8] Change icon 'sizes' to 'size' and add localization completeness validation Co-authored-by: asklar <22989529+asklar@users.noreply.github.com> --- .../mcpb.Tests/CliPackFileValidationTests.cs | 4 +- dotnet/mcpb.Tests/ManifestValidatorTests.cs | 20 +- .../mcpb/Commands/ManifestCommandHelpers.cs | 188 ++++++++++++++++++ dotnet/mcpb/Commands/ValidateCommand.cs | 15 ++ dotnet/mcpb/Core/ManifestModels.cs | 2 +- dotnet/mcpb/Core/ManifestValidator.cs | 8 +- 6 files changed, 220 insertions(+), 17 deletions(-) diff --git a/dotnet/mcpb.Tests/CliPackFileValidationTests.cs b/dotnet/mcpb.Tests/CliPackFileValidationTests.cs index 9fc66ed..13d5f5a 100644 --- a/dotnet/mcpb.Tests/CliPackFileValidationTests.cs +++ b/dotnet/mcpb.Tests/CliPackFileValidationTests.cs @@ -129,7 +129,7 @@ public void Pack_MissingIconsFile_Fails() manifest.ManifestVersion = "0.3"; manifest.Icons = new List { - new() { Src = "icon-16.png", Sizes = "16x16" } + new() { Src = "icon-16.png", Size = "16x16" } }; File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); var (code, _, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); @@ -149,7 +149,7 @@ public void Pack_IconsFilePresent_Succeeds() manifest.Screenshots = null; // Remove screenshots requirement for this test manifest.Icons = new List { - new() { Src = "icon-16.png", Sizes = "16x16" } + new() { Src = "icon-16.png", Size = "16x16" } }; File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); diff --git a/dotnet/mcpb.Tests/ManifestValidatorTests.cs b/dotnet/mcpb.Tests/ManifestValidatorTests.cs index 3413975..20cca95 100644 --- a/dotnet/mcpb.Tests/ManifestValidatorTests.cs +++ b/dotnet/mcpb.Tests/ManifestValidatorTests.cs @@ -172,8 +172,8 @@ public void ValidIcons_Passes() m.ManifestVersion = "0.3"; m.Icons = new List { - new() { Src = "icon-16.png", Sizes = "16x16" }, - new() { Src = "icon-32.png", Sizes = "32x32", Theme = "light" } + new() { Src = "icon-16.png", Size = "16x16" }, + new() { Src = "icon-32.png", Size = "32x32", Theme = "light" } }; var issues = ManifestValidator.Validate(m); Assert.Empty(issues); @@ -186,36 +186,36 @@ public void IconMissingSrc_Fails() m.ManifestVersion = "0.3"; m.Icons = new List { - new() { Src = "", Sizes = "16x16" } + new() { Src = "", Size = "16x16" } }; var issues = ManifestValidator.Validate(m); Assert.Contains(issues, i => i.Path == "icons[0].src"); } [Fact] - public void IconMissingSizes_Fails() + public void IconMissingSize_Fails() { var m = BaseManifest(); m.ManifestVersion = "0.3"; m.Icons = new List { - new() { Src = "icon.png", Sizes = "" } + new() { Src = "icon.png", Size = "" } }; var issues = ManifestValidator.Validate(m); - Assert.Contains(issues, i => i.Path == "icons[0].sizes"); + Assert.Contains(issues, i => i.Path == "icons[0].size"); } [Fact] - public void IconInvalidSizesFormat_Fails() + public void IconInvalidSizeFormat_Fails() { var m = BaseManifest(); m.ManifestVersion = "0.3"; m.Icons = new List { - new() { Src = "icon.png", Sizes = "16" } + new() { Src = "icon.png", Size = "16" } }; var issues = ManifestValidator.Validate(m); - Assert.Contains(issues, i => i.Path == "icons[0].sizes" && i.Message.Contains("WIDTHxHEIGHT")); + Assert.Contains(issues, i => i.Path == "icons[0].size" && i.Message.Contains("WIDTHxHEIGHT")); } [Fact] @@ -225,7 +225,7 @@ public void IconEmptyTheme_Fails() m.ManifestVersion = "0.3"; m.Icons = new List { - new() { Src = "icon.png", Sizes = "16x16", Theme = "" } + new() { Src = "icon.png", Size = "16x16", Theme = "" } }; var issues = ManifestValidator.Validate(m); Assert.Contains(issues, i => i.Path == "icons[0].theme" && i.Message.Contains("empty")); diff --git a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs index 8276b7a..97dc6ee 100644 --- a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs +++ b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs @@ -180,6 +180,194 @@ void CheckFile(string? relativePath, string category) return errors; } + internal static List ValidateLocalizationCompleteness(McpbManifest manifest, string baseDir, HashSet? rootProps = null) + { + var errors = new List(); + + if (manifest.Localization == null) + return errors; + + // Get the resource path pattern and default locale + var resourcePath = manifest.Localization.Resources ?? "mcpb-resources/${locale}.json"; + var defaultLocale = manifest.Localization.DefaultLocale ?? "en-US"; + + // Determine localizable properties present in the manifest + // Only check properties that were explicitly set in the JSON + var localizableProperties = new List(); + if (rootProps != null) + { + if (rootProps.Contains("display_name") && !string.IsNullOrWhiteSpace(manifest.DisplayName)) + localizableProperties.Add("display_name"); + if (rootProps.Contains("description") && !string.IsNullOrWhiteSpace(manifest.Description)) + localizableProperties.Add("description"); + if (rootProps.Contains("long_description") && !string.IsNullOrWhiteSpace(manifest.LongDescription)) + localizableProperties.Add("long_description"); + if (rootProps.Contains("author") && !string.IsNullOrWhiteSpace(manifest.Author?.Name)) + localizableProperties.Add("author.name"); + if (rootProps.Contains("license") && !string.IsNullOrWhiteSpace(manifest.License)) + localizableProperties.Add("license"); + if (rootProps.Contains("keywords") && manifest.Keywords != null && manifest.Keywords.Count > 0) + localizableProperties.Add("keywords"); + } + else + { + // Fallback if rootProps not provided + if (!string.IsNullOrWhiteSpace(manifest.DisplayName)) localizableProperties.Add("display_name"); + if (!string.IsNullOrWhiteSpace(manifest.Description)) localizableProperties.Add("description"); + if (!string.IsNullOrWhiteSpace(manifest.LongDescription)) localizableProperties.Add("long_description"); + if (!string.IsNullOrWhiteSpace(manifest.Author?.Name)) localizableProperties.Add("author.name"); + if (!string.IsNullOrWhiteSpace(manifest.License) && manifest.License != "MIT") localizableProperties.Add("license"); // Don't check default + if (manifest.Keywords != null && manifest.Keywords.Count > 0) localizableProperties.Add("keywords"); + } + + // Also check tool and prompt descriptions + var toolsWithDescriptions = manifest.Tools?.Where(t => !string.IsNullOrWhiteSpace(t.Description)).ToList() ?? new List(); + var promptsWithDescriptions = manifest.Prompts?.Where(p => !string.IsNullOrWhiteSpace(p.Description)).ToList() ?? new List(); + + if (localizableProperties.Count == 0 && toolsWithDescriptions.Count == 0 && promptsWithDescriptions.Count == 0) + return errors; // Nothing to localize + + // Find all locale files by scanning the directory pattern + var localeFiles = FindLocaleFiles(resourcePath, baseDir, defaultLocale); + + if (localeFiles.Count == 0) + return errors; // No additional locale files found, nothing to validate + + // Check each locale file for completeness + foreach (var (locale, filePath) in localeFiles) + { + if (locale == defaultLocale) + continue; // Skip default locale (values are in main manifest) + + try + { + if (!File.Exists(filePath)) + { + errors.Add($"Locale file not found: {filePath} (for locale {locale})"); + continue; + } + + var localeJson = File.ReadAllText(filePath); + using var localeDoc = JsonDocument.Parse(localeJson); + var root = localeDoc.RootElement; + + // Check for localizable properties + foreach (var prop in localizableProperties) + { + if (prop == "author.name") + { + if (!root.TryGetProperty("author", out var authorElem) || + !authorElem.TryGetProperty("name", out _)) + { + errors.Add($"Missing localization for '{prop}' in {locale} ({filePath})"); + } + } + else if (!root.TryGetProperty(prop, out _)) + { + errors.Add($"Missing localization for '{prop}' in {locale} ({filePath})"); + } + } + + // Check tool descriptions + if (toolsWithDescriptions.Count > 0 && root.TryGetProperty("tools", out var toolsElem) && toolsElem.ValueKind == JsonValueKind.Array) + { + var localizedTools = toolsElem.EnumerateArray().ToList(); + foreach (var tool in toolsWithDescriptions) + { + var found = localizedTools.Any(t => + t.TryGetProperty("name", out var nameElem) && + nameElem.GetString() == tool.Name && + t.TryGetProperty("description", out _)); + + if (!found) + { + errors.Add($"Missing localized description for tool '{tool.Name}' in {locale} ({filePath})"); + } + } + } + + // Check prompt descriptions + if (promptsWithDescriptions.Count > 0 && root.TryGetProperty("prompts", out var promptsElem) && promptsElem.ValueKind == JsonValueKind.Array) + { + var localizedPrompts = promptsElem.EnumerateArray().ToList(); + foreach (var prompt in promptsWithDescriptions) + { + var found = localizedPrompts.Any(p => + p.TryGetProperty("name", out var nameElem) && + nameElem.GetString() == prompt.Name && + p.TryGetProperty("description", out _)); + + if (!found) + { + errors.Add($"Missing localized description for prompt '{prompt.Name}' in {locale} ({filePath})"); + } + } + } + } + catch (Exception ex) + { + errors.Add($"Error reading locale file {filePath}: {ex.Message}"); + } + } + + return errors; + } + + private static List<(string locale, string filePath)> FindLocaleFiles(string resourcePattern, string baseDir, string defaultLocale) + { + var localeFiles = new List<(string, string)>(); + + // Extract the directory and file pattern + var patternIndex = resourcePattern.IndexOf("${locale}", StringComparison.OrdinalIgnoreCase); + if (patternIndex < 0) + return localeFiles; + + var beforePlaceholder = resourcePattern.Substring(0, patternIndex); + var afterPlaceholder = resourcePattern.Substring(patternIndex + "${locale}".Length); + + var lastSlash = beforePlaceholder.LastIndexOfAny(new[] { '/', '\\' }); + string dirPath, filePrefix; + + if (lastSlash >= 0) + { + dirPath = beforePlaceholder.Substring(0, lastSlash); + filePrefix = beforePlaceholder.Substring(lastSlash + 1); + } + else + { + dirPath = ""; + filePrefix = beforePlaceholder; + } + + var fullDirPath = string.IsNullOrEmpty(dirPath) ? baseDir : Path.Combine(baseDir, dirPath.Replace('/', Path.DirectorySeparatorChar)); + + if (!Directory.Exists(fullDirPath)) + return localeFiles; + + // Find all files matching the pattern + var searchPattern = filePrefix + "*" + afterPlaceholder; + var files = Directory.GetFiles(fullDirPath, searchPattern, SearchOption.TopDirectoryOnly); + + foreach (var file in files) + { + var fileName = Path.GetFileName(file); + + // Extract locale from filename + if (fileName.StartsWith(filePrefix) && fileName.EndsWith(afterPlaceholder)) + { + var localeStart = filePrefix.Length; + var localeEnd = fileName.Length - afterPlaceholder.Length; + if (localeEnd > localeStart) + { + var locale = fileName.Substring(localeStart, localeEnd - localeStart); + localeFiles.Add((locale, file)); + } + } + } + + return localeFiles; + } + internal static async Task DiscoverCapabilitiesAsync( string dir, McpbManifest manifest, diff --git a/dotnet/mcpb/Commands/ValidateCommand.cs b/dotnet/mcpb/Commands/ValidateCommand.cs index 5f058b2..659c1e0 100644 --- a/dotnet/mcpb/Commands/ValidateCommand.cs +++ b/dotnet/mcpb/Commands/ValidateCommand.cs @@ -90,6 +90,15 @@ static void PrintWarnings(IEnumerable warnings, bool toError) var currentWarnings = new List(warnings); var additionalErrors = new List(); + // Parse JSON to get root properties for localization validation + HashSet? rootProps = null; + using (var doc = JsonDocument.Parse(json)) + { + rootProps = new HashSet(StringComparer.OrdinalIgnoreCase); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + foreach (var p in doc.RootElement.EnumerateObject()) rootProps.Add(p.Name); + } + if (!string.IsNullOrWhiteSpace(dirname)) { var baseDir = Path.GetFullPath(dirname); @@ -107,6 +116,12 @@ static void PrintWarnings(IEnumerable warnings, bool toError) additionalErrors.Add($"ERROR: {err}"); } + var localizationErrors = ManifestCommandHelpers.ValidateLocalizationCompleteness(manifest, baseDir, rootProps); + foreach (var err in localizationErrors) + { + additionalErrors.Add($"ERROR: {err}"); + } + var discovery = await ManifestCommandHelpers.DiscoverCapabilitiesAsync( baseDir, manifest, diff --git a/dotnet/mcpb/Core/ManifestModels.cs b/dotnet/mcpb/Core/ManifestModels.cs index a32ec93..b546bcc 100644 --- a/dotnet/mcpb/Core/ManifestModels.cs +++ b/dotnet/mcpb/Core/ManifestModels.cs @@ -83,7 +83,7 @@ public class McpbManifestLocalization public class McpbManifestIcon { [JsonPropertyName("src")] public string Src { get; set; } = string.Empty; - [JsonPropertyName("sizes")] public string Sizes { get; set; } = string.Empty; + [JsonPropertyName("size")] public string Size { get; set; } = string.Empty; [JsonPropertyName("theme")] public string? Theme { get; set; } } diff --git a/dotnet/mcpb/Core/ManifestValidator.cs b/dotnet/mcpb/Core/ManifestValidator.cs index cc49b63..2dc4e3b 100644 --- a/dotnet/mcpb/Core/ManifestValidator.cs +++ b/dotnet/mcpb/Core/ManifestValidator.cs @@ -137,10 +137,10 @@ void CheckUrl(string? url, string path) var icon = m.Icons[i]; if (string.IsNullOrWhiteSpace(icon.Src)) issues.Add(new($"icons[{i}].src", "src is required")); - if (string.IsNullOrWhiteSpace(icon.Sizes)) - issues.Add(new($"icons[{i}].sizes", "sizes is required")); - else if (!Regex.IsMatch(icon.Sizes, "^\\d+x\\d+$")) - issues.Add(new($"icons[{i}].sizes", "sizes must be in the format \"WIDTHxHEIGHT\" (e.g., \"16x16\")")); + if (string.IsNullOrWhiteSpace(icon.Size)) + issues.Add(new($"icons[{i}].size", "size is required")); + else if (!Regex.IsMatch(icon.Size, "^\\d+x\\d+$")) + issues.Add(new($"icons[{i}].size", "size must be in the format \"WIDTHxHEIGHT\" (e.g., \"16x16\")")); if (icon.Theme != null && string.IsNullOrWhiteSpace(icon.Theme)) issues.Add(new($"icons[{i}].theme", "theme cannot be empty when provided")); } From c1f1a9a4f55b8d696dbab27031930eae2c0d7cf2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 23:51:17 +0000 Subject: [PATCH 5/8] Remove license from localizable properties list Co-authored-by: asklar <22989529+asklar@users.noreply.github.com> --- dotnet/mcpb/Commands/ManifestCommandHelpers.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs index 97dc6ee..59f4650 100644 --- a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs +++ b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs @@ -204,8 +204,6 @@ internal static List ValidateLocalizationCompleteness(McpbManifest manif localizableProperties.Add("long_description"); if (rootProps.Contains("author") && !string.IsNullOrWhiteSpace(manifest.Author?.Name)) localizableProperties.Add("author.name"); - if (rootProps.Contains("license") && !string.IsNullOrWhiteSpace(manifest.License)) - localizableProperties.Add("license"); if (rootProps.Contains("keywords") && manifest.Keywords != null && manifest.Keywords.Count > 0) localizableProperties.Add("keywords"); } @@ -216,7 +214,6 @@ internal static List ValidateLocalizationCompleteness(McpbManifest manif if (!string.IsNullOrWhiteSpace(manifest.Description)) localizableProperties.Add("description"); if (!string.IsNullOrWhiteSpace(manifest.LongDescription)) localizableProperties.Add("long_description"); if (!string.IsNullOrWhiteSpace(manifest.Author?.Name)) localizableProperties.Add("author.name"); - if (!string.IsNullOrWhiteSpace(manifest.License) && manifest.License != "MIT") localizableProperties.Add("license"); // Don't check default if (manifest.Keywords != null && manifest.Keywords.Count > 0) localizableProperties.Add("keywords"); } From 442318097851309f7cfd92195d8475fd1c926d66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 00:24:20 +0000 Subject: [PATCH 6/8] Use strongly-typed model for localization resource files with System.Text.Json source generator Co-authored-by: asklar <22989529+asklar@users.noreply.github.com> --- .../mcpb/Commands/ManifestCommandHelpers.cs | 45 ++++++++++--------- dotnet/mcpb/Core/ManifestModels.cs | 28 ++++++++++++ dotnet/mcpb/Json/JsonContext.cs | 4 ++ 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs index 59f4650..62b82c4 100644 --- a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs +++ b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs @@ -245,36 +245,42 @@ internal static List ValidateLocalizationCompleteness(McpbManifest manif } var localeJson = File.ReadAllText(filePath); - using var localeDoc = JsonDocument.Parse(localeJson); - var root = localeDoc.RootElement; + var localeResource = JsonSerializer.Deserialize(localeJson, McpbJsonContext.Default.McpbLocalizationResource); + + if (localeResource == null) + { + errors.Add($"Failed to parse locale file: {filePath}"); + continue; + } // Check for localizable properties foreach (var prop in localizableProperties) { - if (prop == "author.name") + var isMissing = prop switch { - if (!root.TryGetProperty("author", out var authorElem) || - !authorElem.TryGetProperty("name", out _)) - { - errors.Add($"Missing localization for '{prop}' in {locale} ({filePath})"); - } - } - else if (!root.TryGetProperty(prop, out _)) + "display_name" => string.IsNullOrWhiteSpace(localeResource.DisplayName), + "description" => string.IsNullOrWhiteSpace(localeResource.Description), + "long_description" => string.IsNullOrWhiteSpace(localeResource.LongDescription), + "author.name" => localeResource.Author == null || string.IsNullOrWhiteSpace(localeResource.Author.Name), + "keywords" => localeResource.Keywords == null || localeResource.Keywords.Count == 0, + _ => false + }; + + if (isMissing) { errors.Add($"Missing localization for '{prop}' in {locale} ({filePath})"); } } // Check tool descriptions - if (toolsWithDescriptions.Count > 0 && root.TryGetProperty("tools", out var toolsElem) && toolsElem.ValueKind == JsonValueKind.Array) + if (toolsWithDescriptions.Count > 0) { - var localizedTools = toolsElem.EnumerateArray().ToList(); + var localizedTools = localeResource.Tools ?? new List(); foreach (var tool in toolsWithDescriptions) { var found = localizedTools.Any(t => - t.TryGetProperty("name", out var nameElem) && - nameElem.GetString() == tool.Name && - t.TryGetProperty("description", out _)); + t.Name == tool.Name && + !string.IsNullOrWhiteSpace(t.Description)); if (!found) { @@ -284,15 +290,14 @@ internal static List ValidateLocalizationCompleteness(McpbManifest manif } // Check prompt descriptions - if (promptsWithDescriptions.Count > 0 && root.TryGetProperty("prompts", out var promptsElem) && promptsElem.ValueKind == JsonValueKind.Array) + if (promptsWithDescriptions.Count > 0) { - var localizedPrompts = promptsElem.EnumerateArray().ToList(); + var localizedPrompts = localeResource.Prompts ?? new List(); foreach (var prompt in promptsWithDescriptions) { var found = localizedPrompts.Any(p => - p.TryGetProperty("name", out var nameElem) && - nameElem.GetString() == prompt.Name && - p.TryGetProperty("description", out _)); + p.Name == prompt.Name && + !string.IsNullOrWhiteSpace(p.Description)); if (!found) { diff --git a/dotnet/mcpb/Core/ManifestModels.cs b/dotnet/mcpb/Core/ManifestModels.cs index b546bcc..ded212e 100644 --- a/dotnet/mcpb/Core/ManifestModels.cs +++ b/dotnet/mcpb/Core/ManifestModels.cs @@ -87,6 +87,34 @@ public class McpbManifestIcon [JsonPropertyName("theme")] public string? Theme { get; set; } } +public class McpbLocalizationResourceAuthor +{ + [JsonPropertyName("name")] public string? Name { get; set; } +} + +public class McpbLocalizationResourceTool +{ + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("description")] public string? Description { get; set; } +} + +public class McpbLocalizationResourcePrompt +{ + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("description")] public string? Description { get; set; } +} + +public class McpbLocalizationResource +{ + [JsonPropertyName("display_name")] public string? DisplayName { get; set; } + [JsonPropertyName("description")] public string? Description { get; set; } + [JsonPropertyName("long_description")] public string? LongDescription { get; set; } + [JsonPropertyName("author")] public McpbLocalizationResourceAuthor? Author { get; set; } + [JsonPropertyName("keywords")] public List? Keywords { get; set; } + [JsonPropertyName("tools")] public List? Tools { get; set; } + [JsonPropertyName("prompts")] public List? Prompts { get; set; } +} + public class McpbInitializeResult { [JsonPropertyName("protocolVersion")] public string? ProtocolVersion { get; set; } diff --git a/dotnet/mcpb/Json/JsonContext.cs b/dotnet/mcpb/Json/JsonContext.cs index 2d58af2..f3945e9 100644 --- a/dotnet/mcpb/Json/JsonContext.cs +++ b/dotnet/mcpb/Json/JsonContext.cs @@ -15,6 +15,10 @@ namespace Mcpb.Json; [JsonSerializable(typeof(McpbToolsListResult))] [JsonSerializable(typeof(McpbManifestLocalization))] [JsonSerializable(typeof(McpbManifestIcon))] +[JsonSerializable(typeof(McpbLocalizationResource))] +[JsonSerializable(typeof(McpbLocalizationResourceAuthor))] +[JsonSerializable(typeof(McpbLocalizationResourceTool))] +[JsonSerializable(typeof(McpbLocalizationResourcePrompt))] [JsonSerializable(typeof(System.Text.Json.JsonElement))] [JsonSerializable(typeof(int))] [JsonSerializable(typeof(long))] From a34ca60a6356020cb99a36b2ed7b1559979f47e9 Mon Sep 17 00:00:00 2001 From: Alexander Sklar Date: Thu, 30 Oct 2025 17:49:59 -0700 Subject: [PATCH 7/8] Add 'dotnet' branch to workflow triggers --- .github/workflows/test-dotnet.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-dotnet.yml b/.github/workflows/test-dotnet.yml index 93efdf5..d17b5ca 100644 --- a/.github/workflows/test-dotnet.yml +++ b/.github/workflows/test-dotnet.yml @@ -5,6 +5,7 @@ on: branches: - main - user/asklar/dotnet + - dotnet paths: - 'dotnet/**' - 'examples/**' From 8a2f07b718f4ed9d935c70f75a186d9c78ad1889 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 1 Nov 2025 23:00:08 +0000 Subject: [PATCH 8/8] Bump version to 0.3.3 Co-authored-by: asklar <22989529+asklar@users.noreply.github.com> --- dotnet/mcpb/mcpb.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/mcpb/mcpb.csproj b/dotnet/mcpb/mcpb.csproj index 50486a4..b7df94a 100644 --- a/dotnet/mcpb/mcpb.csproj +++ b/dotnet/mcpb/mcpb.csproj @@ -9,7 +9,7 @@ true mcpb Mcpb.Cli - 0.3.2 + 0.3.3 Alexander Sklar CLI tool for building MCP Bundles (.mcpb) MCP;MCPB;CLI;bundles;DXT;ModelContextProtocol