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/**' diff --git a/dotnet/mcpb.Tests/CliPackFileValidationTests.cs b/dotnet/mcpb.Tests/CliPackFileValidationTests.cs index e6d3750..13d5f5a 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", Size = "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", Size = "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..20cca95 100644 --- a/dotnet/mcpb.Tests/ManifestValidatorTests.cs +++ b/dotnet/mcpb.Tests/ManifestValidatorTests.cs @@ -96,4 +96,138 @@ 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 LocalizationWithDefaults_Passes() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + // Resources and DefaultLocale are optional with defaults + m.Localization = new McpbManifestLocalization + { + Resources = null, // defaults to "mcpb-resources/${locale}.json" + DefaultLocale = null // defaults to "en-US" + }; + var issues = ManifestValidator.Validate(m); + Assert.Empty(issues); + } + + [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 LocalizationEmptyObject_PassesWithDefaults() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + // Empty localization object should use defaults + m.Localization = new McpbManifestLocalization(); + var issues = ManifestValidator.Validate(m); + Assert.Empty(issues); + } + + [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", Size = "16x16" }, + new() { Src = "icon-32.png", Size = "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 = "", Size = "16x16" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "icons[0].src"); + } + + [Fact] + public void IconMissingSize_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + new() { Src = "icon.png", Size = "" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "icons[0].size"); + } + + [Fact] + public void IconInvalidSizeFormat_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + new() { Src = "icon.png", Size = "16" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "icons[0].size" && i.Message.Contains("WIDTHxHEIGHT")); + } + + [Fact] + public void IconEmptyTheme_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + 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 010f33a..62b82c4 100644 --- a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs +++ b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs @@ -147,9 +147,229 @@ 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) + { + // Check if the localization resources path exists + // 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)) + { + errors.Add($"Missing localization resources for default locale: {defaultLocalePath}"); + } + } + 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("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 (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); + 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) + { + var isMissing = prop switch + { + "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) + { + var localizedTools = localeResource.Tools ?? new List(); + foreach (var tool in toolsWithDescriptions) + { + var found = localizedTools.Any(t => + t.Name == tool.Name && + !string.IsNullOrWhiteSpace(t.Description)); + + if (!found) + { + errors.Add($"Missing localized description for tool '{tool.Name}' in {locale} ({filePath})"); + } + } + } + + // Check prompt descriptions + if (promptsWithDescriptions.Count > 0) + { + var localizedPrompts = localeResource.Prompts ?? new List(); + foreach (var prompt in promptsWithDescriptions) + { + var found = localizedPrompts.Any(p => + p.Name == prompt.Name && + !string.IsNullOrWhiteSpace(p.Description)); + + 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 e4e8aa9..ded212e 100644 --- a/dotnet/mcpb/Core/ManifestModels.cs +++ b/dotnet/mcpb/Core/ManifestModels.cs @@ -74,6 +74,47 @@ public class McpbUserConfigOption [JsonPropertyName("max")] public double? Max { get; set; } } +public class McpbManifestLocalization +{ + [JsonPropertyName("resources")] public string? Resources { get; set; } + [JsonPropertyName("default_locale")] public string? DefaultLocale { get; set; } +} + +public class McpbManifestIcon +{ + [JsonPropertyName("src")] public string Src { get; set; } = string.Empty; + [JsonPropertyName("size")] public string Size { get; set; } = string.Empty; + [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; } @@ -115,7 +156,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..2dc4e3b 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) + { + // 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")); + + // 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")); + } + + 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.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")); + } + } + return issues; } diff --git a/dotnet/mcpb/Json/JsonContext.cs b/dotnet/mcpb/Json/JsonContext.cs index e3a53a9..f3945e9 100644 --- a/dotnet/mcpb/Json/JsonContext.cs +++ b/dotnet/mcpb/Json/JsonContext.cs @@ -13,6 +13,12 @@ namespace Mcpb.Json; [JsonSerializable(typeof(McpbStaticResponses))] [JsonSerializable(typeof(McpbInitializeResult))] [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))] 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