diff --git a/.github/workflows/test-dotnet.yml b/.github/workflows/test-dotnet.yml new file mode 100644 index 0000000..93efdf5 --- /dev/null +++ b/.github/workflows/test-dotnet.yml @@ -0,0 +1,290 @@ +name: Test .NET Tool + +on: + pull_request: + branches: + - main + - user/asklar/dotnet + paths: + - 'dotnet/**' + - 'examples/**' + - '.github/workflows/test-dotnet.yml' + push: + branches: + - main + - user/asklar/dotnet + paths: + - 'dotnet/**' + - 'examples/**' + - '.github/workflows/test-dotnet.yml' + +permissions: + contents: read + +jobs: + test-dotnet: + name: Test .NET Tool + strategy: + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup .NET + uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 + with: + dotnet-version: '8.0.x' + + - name: Setup Node.js (for examples) + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '20.x' + + # Step a) Run .NET tests + - name: Run .NET Tests + shell: bash + run: | + cd dotnet + dotnet restore + dotnet build -c Release + dotnet test -c Release --no-build --verbosity normal + + # Step b) Validate basic CLI functionality + - name: Test CLI - Help Command + shell: bash + run: | + cd dotnet + dotnet run --project mcpb/mcpb.csproj -- --help + + - name: Test CLI - Version Command + shell: bash + run: | + cd dotnet + dotnet run --project mcpb/mcpb.csproj -- --version + + # Step c) Test with examples - hello-world-node + - name: Setup hello-world-node Example + shell: bash + run: | + cd examples/hello-world-node + npm install + + - name: Test CLI - Validate hello-world-node Manifest + shell: bash + run: | + cd dotnet + dotnet run --project mcpb/mcpb.csproj -- validate ../examples/hello-world-node/manifest.json + + - name: Test CLI - Pack hello-world-node (with update and output) + shell: bash + run: | + cd examples/hello-world-node + echo "=== Original Manifest ===" + cat manifest.json + echo "" + echo "=== Running mcpb pack --update ===" + dotnet run --project ../../dotnet/mcpb/mcpb.csproj -- pack . hello-world-test.mcpb --update + echo "" + echo "=== Updated Manifest with _meta ===" + cat manifest.json + echo "" + echo "=== Verifying _meta field was added ===" + if grep -q '"_meta"' manifest.json; then + echo "✓ _meta field found in manifest" + else + echo "✗ _meta field NOT found in manifest" + exit 1 + fi + echo "" + echo "=== Verifying static_responses ===" + if grep -q '"static_responses"' manifest.json; then + echo "✓ static_responses found in manifest" + else + echo "✗ static_responses NOT found in manifest" + exit 1 + fi + echo "" + echo "=== Verifying inputSchema is present in tools/list ===" + if grep -q '"inputSchema"' manifest.json; then + echo "✓ inputSchema found in manifest" + echo "" + echo "=== Extracting tool schema from _meta ===" + cat manifest.json | grep -A 50 '"tools/list"' | head -50 + else + echo "✗ inputSchema NOT found in manifest" + exit 1 + fi + echo "" + echo "=== Verifying initialize response with protocolVersion ===" + if grep -q '"protocolVersion"' manifest.json; then + echo "✓ protocolVersion found" + echo "" + echo "=== Initialize response ===" + cat manifest.json | grep -A 20 '"initialize"' | head -20 + else + echo "✗ protocolVersion NOT found" + exit 1 + fi + + # Test with file-system-node example + - name: Setup file-system-node Example + shell: bash + run: | + cd examples/file-system-node + npm install + + - name: Test CLI - Validate file-system-node Manifest + shell: bash + run: | + cd dotnet + dotnet run --project mcpb/mcpb.csproj -- validate ../examples/file-system-node/manifest.json + + - name: Test CLI - Pack file-system-node (no update) + shell: bash + run: | + cd examples/file-system-node + echo "=== Testing pack without --update ===" + dotnet run --project ../../dotnet/mcpb/mcpb.csproj -- pack . file-system-test.mcpb --force || true + echo "" + echo "=== Manifest (should be unchanged) ===" + cat manifest.json + + - name: Test CLI - Update file-system-node with schema discovery + shell: bash + run: | + cd examples/file-system-node + echo "=== File-system-node example requires allowed_directories argument ===" + echo "=== Temporarily modifying manifest to include test directory ===" + cp manifest.json manifest.json.backup + TEST_DIR="$(cd .. && pwd)" + echo "Using test directory: $TEST_DIR" + jq --arg dir "$TEST_DIR" '.server.mcp_config.args = ["${__dirname}/server/index.js", $dir]' manifest.json > manifest.json.tmp && mv manifest.json.tmp manifest.json + echo "" + echo "=== Running mcpb pack --update to discover schemas ===" + dotnet run --project ../../dotnet/mcpb/mcpb.csproj -- pack . /tmp/fs-test.mcpb --update || echo "Pack may have warnings" + echo "" + echo "=== FULL UPDATED MANIFEST ===" + cat manifest.json | jq . + echo "" + echo "=== Verifying _meta field exists ===" + if [ "$(cat manifest.json | jq -r '._meta | type')" = "object" ]; then + echo "✓ _meta field is present and is an object" + else + echo "✗ _meta field NOT found or not an object" + mv manifest.json.backup manifest.json + exit 1 + fi + echo "" + echo "=== Verifying com.microsoft.windows in _meta ===" + if [ "$(cat manifest.json | jq -r '._meta["com.microsoft.windows"] | type')" = "object" ]; then + echo "✓ com.microsoft.windows field is present" + else + echo "✗ com.microsoft.windows field NOT found" + mv manifest.json.backup manifest.json + exit 1 + fi + echo "" + echo "=== Verifying static_responses in com.microsoft.windows ===" + if [ "$(cat manifest.json | jq -r '._meta["com.microsoft.windows"].static_responses | type')" = "object" ]; then + echo "✓ static_responses field is present" + else + echo "✗ static_responses field NOT found" + mv manifest.json.backup manifest.json + exit 1 + fi + echo "" + echo "=== Verifying protocolVersion in initialize response ===" + PROTOCOL_VERSION=$(cat manifest.json | jq -r '._meta["com.microsoft.windows"].static_responses.initialize.protocolVersion') + if [ "$PROTOCOL_VERSION" != "null" ] && [ -n "$PROTOCOL_VERSION" ]; then + echo "✓ protocolVersion found: $PROTOCOL_VERSION" + else + echo "✗ protocolVersion NOT found" + mv manifest.json.backup manifest.json + exit 1 + fi + echo "" + echo "=== Verifying tools/list has tools array ===" + TOOLS_COUNT=$(cat manifest.json | jq -r '._meta["com.microsoft.windows"].static_responses["tools/list"].tools | length') + if [ "$TOOLS_COUNT" -gt 0 ]; then + echo "✓ tools/list contains $TOOLS_COUNT tools" + else + echo "✗ tools/list does not contain any tools" + mv manifest.json.backup manifest.json + exit 1 + fi + echo "" + echo "=== Verifying inputSchema for read_file tool ===" + READ_FILE_SCHEMA=$(cat manifest.json | jq '._meta["com.microsoft.windows"].static_responses["tools/list"].tools[] | select(.name == "read_file") | .inputSchema') + if [ "$READ_FILE_SCHEMA" != "null" ] && [ -n "$READ_FILE_SCHEMA" ]; then + echo "✓ inputSchema found for read_file" + echo "$READ_FILE_SCHEMA" | jq . + else + echo "✗ inputSchema NOT found for read_file" + mv manifest.json.backup manifest.json + exit 1 + fi + echo "" + echo "=== Verifying inputSchema has required 'path' property for read_file ===" + HAS_PATH=$(cat manifest.json | jq -r '._meta["com.microsoft.windows"].static_responses["tools/list"].tools[] | select(.name == "read_file") | .inputSchema.properties.path | type') + if [ "$HAS_PATH" = "object" ]; then + echo "✓ read_file inputSchema has 'path' property" + else + echo "✗ read_file inputSchema missing 'path' property" + mv manifest.json.backup manifest.json + exit 1 + fi + echo "" + echo "=== Verifying inputSchema for write_file tool ===" + WRITE_FILE_SCHEMA=$(cat manifest.json | jq '._meta["com.microsoft.windows"].static_responses["tools/list"].tools[] | select(.name == "write_file") | .inputSchema') + if [ "$WRITE_FILE_SCHEMA" != "null" ] && [ -n "$WRITE_FILE_SCHEMA" ]; then + echo "✓ inputSchema found for write_file" + echo "$WRITE_FILE_SCHEMA" | jq . + else + echo "✗ inputSchema NOT found for write_file" + mv manifest.json.backup manifest.json + exit 1 + fi + echo "" + echo "=== Restoring original manifest ===" + mv manifest.json.backup manifest.json + + # Test init command + - name: Test CLI - Init Command + shell: bash + run: | + cd dotnet + mkdir -p ../test-init + cd ../test-init + echo "=== Testing mcpb init ===" + dotnet run --project ../dotnet/mcpb/mcpb.csproj -- init --yes --server-type node --entry-point server/index.js + echo "" + echo "=== Generated Manifest ===" + cat manifest.json + echo "" + echo "=== Verifying manifest was created ===" + if [ -f manifest.json ]; then + echo "✓ manifest.json created" + else + echo "✗ manifest.json NOT created" + exit 1 + fi + + # Clean up test artifacts + - name: Cleanup + if: always() + shell: bash + run: | + cd examples/hello-world-node + git checkout manifest.json || true + rm -f hello-world-test.mcpb || true + cd ../file-system-node + git checkout manifest.json || true + rm -f file-system-test.mcpb file-system-schema-test.mcpb || true + cd ../../ + rm -rf test-init || true diff --git a/.gitignore b/.gitignore index ca98aa1..e879568 100644 --- a/.gitignore +++ b/.gitignore @@ -107,4 +107,25 @@ self-signed-key.pem invalid-json.json **/server/lib/** +# .NET build artifacts +**/[Bb]in/ +**/[Oo]bj/ + +# StyleCop +StyleCopReport.xml +*.pdb +*.tmp +.vs/** + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +**/server/lib/** + .yarn/install-state.gz \ No newline at end of file diff --git a/CLI.md b/CLI.md index 02787f8..cae6be4 100644 --- a/CLI.md +++ b/CLI.md @@ -19,7 +19,7 @@ Options: Commands: init [directory] Create a new MCPB extension manifest - validate Validate a MCPB manifest file + validate [manifest] Validate a MCPB manifest file pack [output] Pack a directory into a MCPB extension sign [options] Sign a MCPB extension file verify Verify the signature of a MCPB extension file @@ -58,7 +58,7 @@ The command will prompt you for: After creating the manifest, it provides helpful next steps based on your server type. -### `mcpb validate ` +### `mcpb validate [path]` Validates a MCPB manifest file against the schema. You can provide either a direct path to a manifest.json file or a directory containing one. @@ -69,8 +69,28 @@ mcpb validate manifest.json # Validate manifest in directory mcpb validate ./my-extension mcpb validate . + +# Validate using --dirname without specifying manifest.json explicitly +mcpb validate --dirname ./my-extension ``` +#### Additional validation with `--dirname` + +Passing `--dirname ` performs deeper checks that require access to the extension's source files: + +- Verifies referenced assets exist relative to the directory (`icon`, each `screenshots` entry, `server.entry_point`, and path-like `server.mcp_config.command`). +- Launches the server (honoring `${__dirname}` tokens) and discovers tools & prompts using the same logic as `mcpb pack`. +- Compares discovered capability names against the manifest and fails if they differ. + +When `--dirname` is supplied without an explicit manifest argument, the CLI automatically resolves `/manifest.json`. Use `--update` alongside `--dirname` to rewrite the manifest in-place with the discovered tool/prompt lists (including `tools_generated` / `prompts_generated` flags). When rewriting, the CLI also copies over tool descriptions and prompt metadata (descriptions, declared arguments, and prompt text) returned by the server. Without `--update`, any mismatch causes the command to fail. + +The discovery step respects the same environment overrides as `mcpb pack`: + +- `MCPB_TOOL_DISCOVERY_JSON` +- `MCPB_PROMPT_DISCOVERY_JSON` + +These allow deterministic testing without launching the server. + ### `mcpb pack [output]` Packs a directory into a MCPB extension file. @@ -89,6 +109,52 @@ The command automatically: - Excludes common development files (.git, node_modules/.cache, .DS_Store, etc.) - Creates a compressed .mcpb file (ZIP with maximum compression) +#### Capability Discovery (Tools & Prompts) + +During packing, the CLI launches your server (based on `server.mcp_config.command` + `args`) and uses the official C# MCP client to request both tool and prompt listings. It compares the discovered tool names (`tools` array) and prompt names (`prompts` array) with those declared in `manifest.json`. + +If they differ: + +- Default: packing fails with an error explaining the mismatch. +- `--force`: continue packing despite any mismatch (does not modify the manifest). +- `--update`: overwrite the `tools` and/or `prompts` list in `manifest.json` with the discovered sets (also sets `tools_generated: true` and/or `prompts_generated: true`) and persists the discovered descriptions plus prompt arguments/text when available. +- `--no-discover`: skip dynamic discovery entirely (useful offline or when the server cannot be executed locally). + +Environment overrides for tests/CI: + +- `MCPB_TOOL_DISCOVERY_JSON` JSON array of tool names. +- `MCPB_PROMPT_DISCOVERY_JSON` JSON array of prompt names. + If either is set, the server process is not launched for that capability. + +#### Referenced File Validation + +Before launching the server or writing the archive, `mcpb pack` now validates that certain files referenced in `manifest.json` actually exist relative to the extension directory: + +- `icon` (if specified) +- `server.entry_point` +- Path-like `server.mcp_config.command` values (those containing `/`, `\\`, `${__dirname}`, starting with `./` or `..`, or ending in common script/binary extensions such as `.js`, `.py`, `.exe`) +- Each file in `screenshots` (if specified) + +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. + +Commands (e.g. `node`, `python`) that are not path-like are not validated—they are treated as executables resolved by the environment. + +Examples: + +```bash +## Fail if mismatch +mcpb pack . + +# Force success even if mismatch +mcpb pack . --force + +## Update manifest.json to discovered tools/prompts +mcpb pack . --update + +# Skip discovery (behaves like legacy pack) +mcpb pack . --no-discover +``` + ### `mcpb sign ` Signs a MCPB extension file with a certificate. diff --git a/README.md b/README.md index 608023e..94d469a 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ AI tools like Claude Code are particularly good at creating MCP bundles when inf > - https://github.com/anthropics/mcpb/tree/main/examples - Reference implementations including a "Hello World" example > 2. **Create a proper bundle structure:** > - Generate a valid manifest.json following the MANIFEST.md spec -> - Implement an MCP server using @modelcontextprotocol/sdk with proper tool definitions +> - Implement an MCP server with proper tool definitions. Use the right library for the target language: + - TypeScript: @modelcontextprotocol/sdk using a NodeJS console app. + - C#: the ModelContextProtocol pre-release NuGet package, as a console stdio exe. Only write AoT-friendly code. > - Include proper error handling, security measures, and timeout management > 3. **Follow best development practices:** > - Implement proper MCP protocol communication via stdio transport @@ -125,9 +127,10 @@ bundle.mcpb (ZIP file) **Binary Bundles:** -- Static linking preferred for maximum compatibility +- Static linking preferred for maximum compatibility. - Include all required shared libraries if dynamic linking used -- Test on clean systems without development tools +- Test on clean systems without development tools. +- For C#, publish as AoT and include all necessary runtime assets like DLLs. # Contributing @@ -160,3 +163,33 @@ yarn test # License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## .NET CLI Port (Experimental) + +An experimental .NET 8 global tool implementation of the `mcpb` CLI is included under `dotnet/` providing near feature parity with the TypeScript version: + +- Interactive `init` (mirrors all TS prompts; `--yes` for non-interactive defaults). Default server type in .NET flow is `binary` instead of `node`. +- `pack` reproduces archive listing, size formatting, grouping of deep paths, SHA1 shasum, ignored file counts via `.mcpbignore`. +- `validate`, `sign`, `verify`, `info`, `unsign` commands align output wording with the TS CLI (chain trust validation TBD; current .NET verify checks signature cryptographic validity only). +- Signing uses Windows/.NET `SignedCms` (detached PKCS#7) rather than forge; signature block format is identical (`MCPB_SIG_V1` / `MCPB_SIG_END`). + +Install (once published): +```pwsh +dotnet tool install --global mcpb +``` +Local build for testing: +```pwsh +cd dotnet/mcpb +dotnet pack -c Release +dotnet tool install --global --add-source . mcpb +``` +Then run: +```pwsh +mcpb init +mcpb pack +``` + +Known gaps vs TS implementation: +- Certificate chain trust / intermediate certificate handling not yet implemented. +- `clean` command not yet ported. +- Integration tests for full CLI flows are pending. diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 0000000..a8a0e50 --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,36 @@ +# MCPB .NET CLI + +Experimental .NET port of the MCPB CLI. + +## Build + +```pwsh +cd dotnet/mcpb +dotnet build -c Release +``` + +## Install as local tool + +```pwsh +cd dotnet/mcpb +dotnet pack -c Release +# Find generated nupkg in bin/Release +dotnet tool install --global Mcpb.Cli --add-source ./bin/Release +``` + +## Commands + +| Command | Description | +| --------------------------------------------------------------------------------------- | -------------------------------- | +| `mcpb init [directory] [--server-type node\|python\|binary\|auto] [--entry-point path]` | Create manifest.json | +| `mcpb validate [manifest\|directory]` | Validate manifest | +| `mcpb pack [directory] [output]` | Create .mcpb archive | +| `mcpb unpack [outputDir]` | Extract archive | +| `mcpb sign [--cert cert.pem --key key.pem --self-signed]` | Sign bundle | +| `mcpb verify ` | Verify signature | +| `mcpb info ` | Show bundle info (and signature) | +| `mcpb unsign ` | Remove signature | + +## License Compliance + +MIT licensed diff --git a/dotnet/mcpb.Tests/AssemblyInfo.cs b/dotnet/mcpb.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..63eddfa --- /dev/null +++ b/dotnet/mcpb.Tests/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] +// Allow accessing internal helpers for validation +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("mcpb.Tests")] diff --git a/dotnet/mcpb.Tests/CliInitTests.cs b/dotnet/mcpb.Tests/CliInitTests.cs new file mode 100644 index 0000000..bfeb833 --- /dev/null +++ b/dotnet/mcpb.Tests/CliInitTests.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using Xunit; +using Mcpb.Json; +using Mcpb.Core; + +namespace Mcpb.Tests; + +public class CliInitTests +{ + [Fact] + public void InitYes_CreatesManifestWithBinaryDefaults() + { + var temp = Path.Combine(Path.GetTempPath(), "mcpb_cli_init_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(temp); + var root = Mcpb.Commands.CliRoot.Build(); + var prevCwd = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(temp); + try + { + using var swOut = new StringWriter(); + using var swErr = new StringWriter(); + var code = CommandRunner.Invoke(root, new[]{"init","--yes"}, swOut, swErr); + Assert.Equal(0, code); + } + finally { Directory.SetCurrentDirectory(prevCwd); } + var manifestPath = Path.Combine(temp, "manifest.json"); + Assert.True(File.Exists(manifestPath), "manifest.json not created"); + var json = File.ReadAllText(manifestPath); + var manifest = JsonSerializer.Deserialize(json, McpbJsonContext.Default.McpbManifest)!; + Assert.Equal("0.2", manifest.ManifestVersion); + Assert.Equal("binary", manifest.Server.Type); + Assert.False(string.IsNullOrWhiteSpace(manifest.Server.EntryPoint)); + Assert.NotNull(manifest.Author); + // Ensure display name not set unless different + if (manifest.DisplayName != null) Assert.NotEqual(manifest.Name, manifest.DisplayName); + } +} \ No newline at end of file diff --git a/dotnet/mcpb.Tests/CliPackFileValidationTests.cs b/dotnet/mcpb.Tests/CliPackFileValidationTests.cs new file mode 100644 index 0000000..e6d3750 --- /dev/null +++ b/dotnet/mcpb.Tests/CliPackFileValidationTests.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using Xunit; +using System.IO; +using Mcpb.Json; + +namespace Mcpb.Tests; + +public class CliPackFileValidationTests +{ + private string CreateTempDir() + { + 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) + { + var root = Mcpb.Commands.CliRoot.Build(); + var prev = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(workingDir); + using var swOut = new StringWriter(); + using var swErr = new StringWriter(); + try + { + var code = CommandRunner.Invoke(root, args, swOut, swErr); + return (code, swOut.ToString(), swErr.ToString()); + } + 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 + { + 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() + { + var dir = CreateTempDir(); + 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(); + 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); + } + + [Fact] + public void Pack_MissingEntryPoint_Fails() + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); + 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)); + var (code, _, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); + Assert.NotEqual(0, code); + Assert.Contains("Missing entry_point file", stderr); + } + + [Fact] + public void Pack_MissingScreenshot_Fails() + { + var dir = CreateTempDir(); + 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)); + var (code, _, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); + Assert.NotEqual(0, code); + Assert.Contains("Missing screenshot file", stderr); + } + + [Fact] + public void Pack_PathLikeCommandMissing_Fails() + { + 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(); + // 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)); + var (code, _, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); + Assert.NotEqual(0, code); + Assert.Contains("Missing server.command file", stderr); + } + + [Fact] + public void Pack_AllFilesPresent_Succeeds() + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fakeicon"); + 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(); + // 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)); + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); + Assert.Equal(0, code); + Assert.Contains("demo@", stdout); + Assert.DoesNotContain("Missing", stderr); + } +} diff --git a/dotnet/mcpb.Tests/CliPackPromptDiscoveryTests.cs b/dotnet/mcpb.Tests/CliPackPromptDiscoveryTests.cs new file mode 100644 index 0000000..62d605f --- /dev/null +++ b/dotnet/mcpb.Tests/CliPackPromptDiscoveryTests.cs @@ -0,0 +1,174 @@ +using System.Text.Json; +using Mcpb.Json; +using Xunit; +using System.IO; +using System.Linq; + +namespace Mcpb.Tests; + +public class CliPackPromptDiscoveryTests +{ + private string CreateTempDir() + { + var dir = Path.Combine(Path.GetTempPath(), "mcpb_cli_pack_prompts_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } + private (int exitCode, string stdout, string stderr) InvokeCli(string workingDir, params string[] args) + { + var root = Mcpb.Commands.CliRoot.Build(); + var prev = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(workingDir); + using var swOut = new StringWriter(); + using var swErr = new StringWriter(); + try + { + var code = CommandRunner.Invoke(root, args, swOut, swErr); + return (code, swOut.ToString(), swErr.ToString()); + } + finally { Directory.SetCurrentDirectory(prev); } + } + + private Mcpb.Core.McpbManifest MakeManifest(string[] prompts) + { + return new Mcpb.Core.McpbManifest + { + Name = "demo", + Description = "desc", + Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, + Server = new Mcpb.Core.McpbManifestServer { Type = "binary", EntryPoint = "server/demo", McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/demo" } }, + Prompts = prompts.Select(p => new Mcpb.Core.McpbManifestPrompt { Name = p, Text = "t" }).ToList() + }; + } + + [Fact] + public void Pack_PromptMismatch_Fails() + { + var dir = CreateTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(MakeManifest(new[] { "p1" }), McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[\"p1\",\"p2\"]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir); + Assert.NotEqual(0, code); + Assert.Contains("Prompt list mismatch", stdout + stderr); + } + finally { Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); } + } + + [Fact] + public void Pack_PromptMismatch_Update_Succeeds() + { + var dir = CreateTempDir(); + var manifestPath = Path.Combine(dir, "manifest.json"); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + File.WriteAllText(manifestPath, JsonSerializer.Serialize(MakeManifest(new[] { "p1" }), McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[\"p1\",\"p2\"]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--update"); + Assert.Equal(0, code); + var updated = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), McpbJsonContext.Default.McpbManifest)!; + Assert.Equal(2, updated.Prompts!.Count); + Assert.Equal(false, updated.PromptsGenerated); + } + finally { Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); } + } + + [Fact] + public void Pack_PromptMismatch_Force_Succeeds() + { + var dir = CreateTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(MakeManifest(new[] { "p1" }), McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[\"p1\",\"p2\"]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--force"); + Assert.Equal(0, code); + Assert.Contains("Proceeding due to --force", stdout + stderr); + } + finally { Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); } + } + + [Fact] + public void Pack_Update_PreservesPromptTextWhenDiscoveryMissing() + { + var dir = CreateTempDir(); + var manifestPath = Path.Combine(dir, "manifest.json"); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + var manifest = MakeManifest(new[] { "p1" }); + manifest.Prompts![0].Text = "existing body"; + File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[{\"name\":\"p1\",\"description\":\"Prompt\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--update"); + Assert.Equal(0, code); + Assert.Contains("Prompt 'p1' did not return text during discovery", stdout + stderr); + var updated = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), McpbJsonContext.Default.McpbManifest)!; + Assert.Equal("existing body", updated.Prompts!.Single(p => p.Name == "p1").Text); + Assert.Equal(false, updated.PromptsGenerated); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Pack_Update_DoesNotOverwriteExistingPromptText() + { + var dir = CreateTempDir(); + var manifestPath = Path.Combine(dir, "manifest.json"); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + var manifest = MakeManifest(new[] { "p1" }); + manifest.Prompts![0].Text = "existing body"; + File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[{\"name\":\"p1\",\"description\":\"Prompt\",\"text\":\"discovered body\"}]"); + try + { + var (code, _, _) = InvokeCli(dir, "pack", dir, "--update"); + Assert.Equal(0, code); + var updated = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), McpbJsonContext.Default.McpbManifest)!; + var prompt = Assert.Single(updated.Prompts!, p => p.Name == "p1"); + Assert.Equal("existing body", prompt.Text); + Assert.Equal(false, updated.PromptsGenerated); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); + } + } + [Fact] + public void Pack_Update_KeepsExistingPromptsGeneratedFlag() + { + var dir = CreateTempDir(); + var manifestPath = Path.Combine(dir, "manifest.json"); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + var manifest = MakeManifest(new[] { "p1" }); + manifest.PromptsGenerated = true; + File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[{\"name\":\"p1\",\"description\":\"Prompt\",\"text\":\"body\"}]"); + try + { + var (code, _, _) = InvokeCli(dir, "pack", dir, "--update"); + Assert.Equal(0, code); + var updated = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), McpbJsonContext.Default.McpbManifest)!; + Assert.True(updated.PromptsGenerated == true); + var prompt = Assert.Single(updated.Prompts!, p => p.Name == "p1"); + Assert.Equal("t", prompt.Text); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); + } + } +} diff --git a/dotnet/mcpb.Tests/CliPackToolDiscoveryTests.cs b/dotnet/mcpb.Tests/CliPackToolDiscoveryTests.cs new file mode 100644 index 0000000..7904200 --- /dev/null +++ b/dotnet/mcpb.Tests/CliPackToolDiscoveryTests.cs @@ -0,0 +1,223 @@ +using System.Text.Json; +using Mcpb.Json; +using Xunit; +using System.IO; +using System.Linq; + +namespace Mcpb.Tests; + +public class CliPackToolDiscoveryTests +{ + private string CreateTempDir() + { + var dir = Path.Combine(Path.GetTempPath(), "mcpb_cli_pack_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } + private (int exitCode, string stdout, string stderr) InvokeCli(string workingDir, params string[] args) + { + var root = Mcpb.Commands.CliRoot.Build(); + var prev = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(workingDir); + using var swOut = new StringWriter(); + using var swErr = new StringWriter(); + try + { + var code = CommandRunner.Invoke(root, args, swOut, swErr); + return (code, swOut.ToString(), swErr.ToString()); + } + finally { Directory.SetCurrentDirectory(prev); } + } + + private Mcpb.Core.McpbManifest MakeManifest(IEnumerable tools) + { + return new Mcpb.Core.McpbManifest + { + Name = "demo", + Description = "desc", + Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, + Server = new Mcpb.Core.McpbManifestServer { Type = "binary", EntryPoint = "server/demo", McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/demo" } }, + Tools = tools.Select(t => new Mcpb.Core.McpbManifestTool { Name = t }).ToList() + }; + } + + [Fact] + public void Pack_MatchingTools_Succeeds() + { + var dir = CreateTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + // entry point expected: server/demo per manifest construction + var manifest = MakeManifest(new[] { "a", "b" }); + manifest.Tools![0].Description = "Tool A"; + manifest.Tools![1].Description = "Tool B"; + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"Tool A\"},{\"name\":\"b\",\"description\":\"Tool B\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover=false"); + Assert.Equal(0, code); + Assert.Contains("demo@", stdout); + } + finally { Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); } + } + + [Fact] + public void Pack_MismatchTools_Fails() + { + var dir = CreateTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(MakeManifest(new[] { "a" }), McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"Tool A\"},{\"name\":\"b\",\"description\":\"Tool B\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir); + Assert.NotEqual(0, code); + Assert.Contains("Discovered capabilities differ", (stderr + stdout)); + } + finally { Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); } + } + + [Fact] + public void Pack_MismatchTools_Force_Succeeds() + { + var dir = CreateTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(MakeManifest(new[] { "a" }), McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"Tool A\"},{\"name\":\"b\",\"description\":\"Tool B\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--force"); + Assert.Equal(0, code); + Assert.Contains("Proceeding due to --force", stdout + stderr); + } + finally { Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); } + } + + [Fact] + public void Pack_MismatchTools_Update_UpdatesManifest() + { + var dir = CreateTempDir(); + var manifestPath = Path.Combine(dir, "manifest.json"); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + File.WriteAllText(manifestPath, JsonSerializer.Serialize(MakeManifest(new[] { "a" }), McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"Tool A\"},{\"name\":\"b\",\"description\":\"Tool B\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--update"); + Assert.Equal(0, code); + var updated = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), McpbJsonContext.Default.McpbManifest)!; + var added = Assert.Single(updated.Tools!.Where(t => t.Name == "b")); + Assert.Equal("Tool B", added.Description); + Assert.Equal(2, updated.Tools!.Count); + Assert.Equal(false, updated.ToolsGenerated); + } + finally { Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); } + } + + [Fact] + public void Pack_ToolMetadataMismatch_FailsWithoutUpdate() + { + var dir = CreateTempDir(); + var manifestPath = Path.Combine(dir, "manifest.json"); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + var manifest = MakeManifest(new[] { "a" }); + manifest.Tools![0].Description = "legacy"; + File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"fresh\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir); + Assert.NotEqual(0, code); + Assert.Contains("Tool metadata mismatch", stdout + stderr); + Assert.Contains("description differs", stdout + stderr); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Pack_ToolMetadataMismatch_UpdateRewritesDescriptions() + { + var dir = CreateTempDir(); + var manifestPath = Path.Combine(dir, "manifest.json"); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + var manifest = MakeManifest(new[] { "a" }); + manifest.Tools![0].Description = null; + File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"fresh\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--update"); + Assert.Equal(0, code); + Assert.Contains("Updated manifest.json capabilities", stdout + stderr); + var updated = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), McpbJsonContext.Default.McpbManifest)!; + Assert.Equal("fresh", updated.Tools!.Single(t => t.Name == "a").Description); + Assert.Equal(false, updated.ToolsGenerated); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Pack_Update_KeepsExistingToolsGeneratedFlag() + { + var dir = CreateTempDir(); + var manifestPath = Path.Combine(dir, "manifest.json"); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + var manifest = MakeManifest(new[] { "a" }); + manifest.ToolsGenerated = true; + File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"fresh\"}]"); + try + { + var (code, _, _) = InvokeCli(dir, "pack", dir, "--update"); + Assert.Equal(0, code); + var updated = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), McpbJsonContext.Default.McpbManifest)!; + Assert.True(updated.ToolsGenerated == true); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Pack_Update_DoesNotEscapeApostrophes() + { + var dir = CreateTempDir(); + var manifestPath = Path.Combine(dir, "manifest.json"); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + var manifest = MakeManifest(new[] { "a" }); + File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"Author's Tool\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--update"); + Assert.Equal(0, code); + Assert.Contains("Updated manifest.json capabilities", stdout + stderr); + var jsonText = File.ReadAllText(manifestPath); + Assert.Contains("\"description\": \"Author's Tool\"", jsonText); + Assert.DoesNotContain("\\u0027", jsonText); + var updated = JsonSerializer.Deserialize(jsonText, McpbJsonContext.Default.McpbManifest)!; + Assert.Equal("Author's Tool", updated.Tools!.Single(t => t.Name == "a").Description); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + } + } +} diff --git a/dotnet/mcpb.Tests/CliTestUtils.cs b/dotnet/mcpb.Tests/CliTestUtils.cs new file mode 100644 index 0000000..4de8e8e --- /dev/null +++ b/dotnet/mcpb.Tests/CliTestUtils.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; +using System.Text; + +namespace Mcpb.Tests; + +internal static class CliTestUtils +{ + private static readonly string ProjectPath = ResolveProjectPath(); + + private static string ResolveProjectPath() + { + // AppContext.BaseDirectory -> .../dotnet/mcpb.Tests/bin/Debug/net8.0/ + var baseDir = AppContext.BaseDirectory; + var proj = Path.GetFullPath(Path.Combine(baseDir, "..","..","..","..","mcpb","mcpb.csproj")); + return proj; + } + + public static (int exitCode,string stdout,string stderr) Run(string workingDir, params string[] args) + { + var psi = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = workingDir, + RedirectStandardError = true, + RedirectStandardOutput = true, + RedirectStandardInput = false, + UseShellExecute = false + }; + psi.ArgumentList.Add("run"); + psi.ArgumentList.Add("--project"); + psi.ArgumentList.Add(ProjectPath); + psi.ArgumentList.Add("--"); + foreach (var a in args) psi.ArgumentList.Add(a); + var p = Process.Start(psi)!; + // Synchronous capture avoids potential race with async event handlers finishing after exit. + var stdout = p.StandardOutput.ReadToEnd(); + var stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(); + return (p.ExitCode, stdout, stderr); + } + + // Escape no longer needed with ArgumentList; keep method if future tests rely on it. + private static string Escape(string s) => s; +} diff --git a/dotnet/mcpb.Tests/CliValidateTests.cs b/dotnet/mcpb.Tests/CliValidateTests.cs new file mode 100644 index 0000000..90da6a0 --- /dev/null +++ b/dotnet/mcpb.Tests/CliValidateTests.cs @@ -0,0 +1,476 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Mcpb.Json; +using Xunit; +using Xunit.Abstractions; +using System.Linq; + +namespace Mcpb.Tests; + +public class CliValidateTests +{ + private readonly ITestOutputHelper _output; + + public CliValidateTests(ITestOutputHelper output) + { + _output = output; + } + private string CreateTempDir() + { + var dir = Path.Combine(Path.GetTempPath(), "mcpb_cli_validate_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } + private (int exitCode, string stdout, string stderr) InvokeCli(string workingDir, params string[] args) + { + var root = Mcpb.Commands.CliRoot.Build(); + var prev = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(workingDir); + using var swOut = new StringWriter(); + using var swErr = new StringWriter(); + try + { + var code = CommandRunner.Invoke(root, args, swOut, swErr); + return (code, swOut.ToString(), swErr.ToString()); + } + finally { Directory.SetCurrentDirectory(prev); } + } + [Fact] + public void Validate_ValidManifest_Succeeds() + { + var dir = CreateTempDir(); + var manifest = new Mcpb.Core.McpbManifest { Name = "ok", Description = "desc", Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, Server = new Mcpb.Core.McpbManifestServer { Type = "binary", EntryPoint = "server/ok", McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/ok" } } }; + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + var (code, stdout, stderr) = InvokeCli(dir, "validate", "manifest.json"); + Assert.Equal(0, code); + Assert.Contains("Manifest is valid!", stdout); + Assert.True(string.IsNullOrWhiteSpace(stderr)); + } + + [Fact] + public void Validate_WithDirnameOnly_UsesDefaultManifest() + { + var dir = CreateTempDir(); + var manifest = new Mcpb.Core.McpbManifest + { + Name = "ok", + Description = "desc", + Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, + Server = new Mcpb.Core.McpbManifestServer + { + Type = "binary", + EntryPoint = "server/ok", + McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/ok" } + }, + Tools = new List + { + new() { Name = "dummy", Description = "fake" } + }, + Prompts = new List + { + new() { Name = "prompt1", Description = "desc", Text = "body" } + } + }; + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "ok"), "binary"); + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"dummy\",\"description\":\"fake\"}]"); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[{\"name\":\"prompt1\",\"description\":\"desc\",\"text\":\"body\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "validate", "--dirname", dir); + _output.WriteLine("STDOUT: " + stdout); + _output.WriteLine("STDERR: " + stderr); + Assert.Equal(0, code); + Assert.Contains("Manifest is valid!", stdout); + Assert.True(string.IsNullOrWhiteSpace(stderr)); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Validate_MissingDescription_Fails() + { + var dir = CreateTempDir(); + // Build JSON manually without description + var json = "{" + + "\"manifest_version\":\"0.2\"," + + "\"name\":\"ok\"," + + "\"version\":\"1.0.0\"," + + "\"author\":{\"name\":\"A\"}," + + "\"server\":{\"type\":\"binary\",\"entry_point\":\"server/ok\",\"mcp_config\":{\"command\":\"${__dirname}/server/ok\"}}" + + "}"; + File.WriteAllText(Path.Combine(dir, "manifest.json"), json); + var (code2, stdout2, stderr2) = InvokeCli(dir, "validate", "manifest.json"); + Assert.NotEqual(0, code2); + Assert.Contains("description is required", stderr2); + } + + [Fact] + public void Validate_DxtVersionOnly_Warns() + { + var dir = CreateTempDir(); + // JSON with only dxt_version (deprecated) no manifest_version + var json = "{" + + "\"dxt_version\":\"0.2\"," + + "\"name\":\"ok\"," + + "\"version\":\"1.0.0\"," + + "\"description\":\"desc\"," + + "\"author\":{\"name\":\"A\"}," + + "\"server\":{\"type\":\"binary\",\"entry_point\":\"server/ok\",\"mcp_config\":{\"command\":\"${__dirname}/server/ok\"}}" + + "}"; + File.WriteAllText(Path.Combine(dir, "manifest.json"), json); + var (code3, stdout3, stderr3) = InvokeCli(dir, "validate", "manifest.json"); + Assert.Equal(0, code3); + Assert.Contains("Manifest is valid!", stdout3); + Assert.Contains("deprecated", stdout3 + stderr3, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Validate_WithDirnameMissingFiles_Fails() + { + var dir = CreateTempDir(); + var manifest = 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 + { + Type = "binary", + EntryPoint = "server/demo", + McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/demo" } + } + }; + Directory.CreateDirectory(Path.Combine(dir, "server")); + // Intentionally leave out icon to trigger failure + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + + var (code, stdout, stderr) = InvokeCli(dir, "validate", "manifest.json", "--dirname", dir); + Assert.NotEqual(0, code); + Assert.Contains("Missing icon file", stdout + stderr); + } + + [Fact] + public void Validate_WithDirnameMismatchFailsWithoutUpdate() + { + var dir = CreateTempDir(); + var manifest = new Mcpb.Core.McpbManifest + { + Name = "demo", + Description = "desc", + Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, + Server = new Mcpb.Core.McpbManifestServer + { + Type = "binary", + EntryPoint = "server/demo", + McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/demo" } + }, + Tools = new List { new Mcpb.Core.McpbManifestTool { Name = "a" } }, + Prompts = new List { new Mcpb.Core.McpbManifestPrompt { Name = "p1", Text = "existing" } } + }; + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"Tool A\"},{\"name\":\"b\",\"description\":\"Tool B\"}]"); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[{\"name\":\"p1\",\"description\":\"Prompt A\",\"arguments\":[\"topic\"],\"text\":\"Prompt A body\"},{\"name\":\"p2\",\"description\":\"Prompt B\",\"arguments\":[\"topic\",\"style\"],\"text\":\"Prompt B body\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "validate", "manifest.json", "--dirname", dir); + Assert.NotEqual(0, code); + _output.WriteLine("STDOUT: " + stdout); + _output.WriteLine("STDERR: " + stderr); + Assert.Contains("Tool list mismatch", stdout + stderr); + Assert.Contains("Prompt list mismatch", stdout + stderr); + Assert.Contains("Use --update", stdout + stderr); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Validate_WithDirnameUpdate_RewritesManifest() + { + var dir = CreateTempDir(); + var manifest = new Mcpb.Core.McpbManifest + { + Name = "demo", + Description = "desc", + Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, + Server = new Mcpb.Core.McpbManifestServer + { + Type = "binary", + EntryPoint = "server/demo", + McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/demo" } + }, + Tools = new List { new Mcpb.Core.McpbManifestTool { Name = "a" } }, + Prompts = new List { new Mcpb.Core.McpbManifestPrompt { Name = "p1", Text = "existing" } } + }; + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"Tool A\"},{\"name\":\"b\",\"description\":\"Tool B\"}]"); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[{\"name\":\"p1\",\"description\":\"Prompt A\",\"arguments\":[\"topic\"],\"text\":\"Prompt A body\"},{\"name\":\"p2\",\"description\":\"Prompt B\",\"arguments\":[\"topic\",\"style\"],\"text\":\"Prompt B body\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "validate", "manifest.json", "--dirname", dir, "--update"); + _output.WriteLine("STDOUT: " + stdout); + _output.WriteLine("STDERR: " + stderr); + Assert.Equal(0, code); + Assert.Contains("Updated manifest.json capabilities", stdout + stderr); + var updated = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(dir, "manifest.json")), McpbJsonContext.Default.McpbManifest)!; + Assert.Equal(2, updated.Tools!.Count); + Assert.Equal(2, updated.Prompts!.Count); + Assert.Equal(false, updated.ToolsGenerated); + Assert.Equal(false, updated.PromptsGenerated); + var toolB = Assert.Single(updated.Tools!.Where(t => t.Name == "b")); + Assert.Equal("Tool B", toolB.Description); + var promptB = Assert.Single(updated.Prompts!.Where(p => p.Name == "p2")); + Assert.Equal("Prompt B", promptB.Description); + Assert.Equal(new[] { "topic", "style" }, promptB.Arguments); + Assert.Equal("Prompt B body", promptB.Text); + var promptA = Assert.Single(updated.Prompts!.Where(p => p.Name == "p1")); + Assert.Equal("existing", promptA.Text); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Validate_WithDirnameMetadataMismatchRequiresUpdate() + { + var dir = CreateTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + var manifest = new Mcpb.Core.McpbManifest + { + Name = "demo", + Description = "desc", + Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, + Server = new Mcpb.Core.McpbManifestServer + { + Type = "binary", + EntryPoint = "server/demo", + McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/demo" } + }, + Tools = new List { new() { Name = "a", Description = "legacy" } }, + Prompts = new List { new() { Name = "p1", Description = "old", Arguments = new List { "topic" }, Text = "Old body" } } + }; + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"fresh\"}]"); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[{\"name\":\"p1\",\"description\":\"Prompt new\",\"arguments\":[\"topic\",\"style\"],\"text\":\"New body\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "validate", "manifest.json", "--dirname", dir); + Assert.NotEqual(0, code); + Assert.Contains("metadata mismatch", stdout + stderr, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Use --update", stdout + stderr, StringComparison.OrdinalIgnoreCase); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Validate_WithDirnameMetadataMismatch_UpdateRewritesManifest() + { + var dir = CreateTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + var manifest = new Mcpb.Core.McpbManifest + { + Name = "demo", + Description = "desc", + Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, + Server = new Mcpb.Core.McpbManifestServer + { + Type = "binary", + EntryPoint = "server/demo", + McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/demo" } + }, + Tools = new List { new() { Name = "a", Description = null } }, + Prompts = new List { new() { Name = "p1", Description = null, Arguments = new List { "topic" }, Text = "Old body" } } + }; + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"fresh\"}]"); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[{\"name\":\"p1\",\"description\":\"Prompt new\",\"arguments\":[\"topic\",\"style\"],\"text\":\"New body\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "validate", "manifest.json", "--dirname", dir, "--update"); + Assert.Equal(0, code); + Assert.Contains("Updated manifest.json capabilities", stdout + stderr); + var updated = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(dir, "manifest.json")), McpbJsonContext.Default.McpbManifest)!; + var tool = Assert.Single(updated.Tools!, t => t.Name == "a"); + Assert.Equal("fresh", tool.Description); + var prompt = Assert.Single(updated.Prompts!, p => p.Name == "p1"); + Assert.Equal("Prompt new", prompt.Description); + Assert.Equal(new[] { "topic", "style" }, prompt.Arguments); + Assert.Equal("Old body", prompt.Text); + Assert.Equal(false, updated.ToolsGenerated); + Assert.Equal(false, updated.PromptsGenerated); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Validate_Update_AddsPromptTextWhenMissing() + { + var dir = CreateTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + var manifest = new Mcpb.Core.McpbManifest + { + Name = "demo", + Description = "desc", + Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, + Server = new Mcpb.Core.McpbManifestServer + { + Type = "binary", + EntryPoint = "server/demo", + McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/demo" } + }, + Prompts = new List + { + new() { Name = "p1" } + } + }; + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[{\"name\":\"p1\",\"description\":\"Prompt new\",\"text\":\"New body\"}]"); + try + { + var (code, _, _) = InvokeCli(dir, "validate", "manifest.json", "--dirname", dir, "--update"); + Assert.Equal(0, code); + var updated = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(dir, "manifest.json")), McpbJsonContext.Default.McpbManifest)!; + var prompt = Assert.Single(updated.Prompts!, p => p.Name == "p1"); + Assert.Equal("New body", prompt.Text); + Assert.Equal(false, updated.PromptsGenerated); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Validate_Update_KeepsExistingGeneratedFlags() + { + var dir = CreateTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + var manifest = new Mcpb.Core.McpbManifest + { + Name = "demo", + Description = "desc", + Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, + Server = new Mcpb.Core.McpbManifestServer + { + Type = "binary", + EntryPoint = "server/demo", + McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/demo" } + }, + Tools = new List { new() { Name = "a" } }, + Prompts = new List { new() { Name = "p1", Text = "existing" } }, + ToolsGenerated = true, + PromptsGenerated = true + }; + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"Tool A\"}]"); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[{\"name\":\"p1\",\"description\":\"Prompt A\",\"text\":\"Prompt body\"}]"); + try + { + var (code, _, _) = InvokeCli(dir, "validate", "manifest.json", "--dirname", dir, "--update"); + Assert.Equal(0, code); + var updated = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(dir, "manifest.json")), McpbJsonContext.Default.McpbManifest)!; + Assert.True(updated.ToolsGenerated == true); + Assert.True(updated.PromptsGenerated == true); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Validate_Update_WarnsIfPromptTextMissing() + { + var dir = CreateTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + var manifest = new Mcpb.Core.McpbManifest + { + Name = "demo", + Description = "desc", + Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, + Server = new Mcpb.Core.McpbManifestServer + { + Type = "binary", + EntryPoint = "server/demo", + McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/demo" } + }, + Tools = new List { new() { Name = "a", Description = "legacy" } }, + Prompts = new List { new() { Name = "p1", Description = "old", Text = "existing" } } + }; + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[{\"name\":\"a\",\"description\":\"fresh\"}]"); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", "[{\"name\":\"p1\",\"description\":\"Prompt new\"}]"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "validate", "manifest.json", "--dirname", dir, "--update"); + Assert.Equal(0, code); + Assert.Contains("Updated manifest.json capabilities", stdout + stderr); + Assert.Contains("Manifest is valid!", stdout); + Assert.Contains("Prompt 'p1' did not return text during discovery", stdout + stderr, StringComparison.OrdinalIgnoreCase); + var json = File.ReadAllText(Path.Combine(dir, "manifest.json")); + Assert.Contains("\"text\": \"existing\"", json); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Validate_UpdateWithoutDirname_Fails() + { + var dir = CreateTempDir(); + var manifest = new Mcpb.Core.McpbManifest + { + Name = "demo", + Description = "desc", + Author = new Mcpb.Core.McpbManifestAuthor { Name = "A" }, + Server = new Mcpb.Core.McpbManifestServer + { + Type = "binary", + EntryPoint = "server/demo", + McpConfig = new Mcpb.Core.McpServerConfigWithOverrides { Command = "${__dirname}/server/demo" } + } + }; + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + var (code, stdout, stderr) = InvokeCli(dir, "validate", "manifest.json", "--update"); + Assert.NotEqual(0, code); + Assert.Contains("requires --dirname", stdout + stderr, StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/dotnet/mcpb.Tests/CommandRunner.cs b/dotnet/mcpb.Tests/CommandRunner.cs new file mode 100644 index 0000000..9391e66 --- /dev/null +++ b/dotnet/mcpb.Tests/CommandRunner.cs @@ -0,0 +1,29 @@ +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace Mcpb.Tests; + +internal static class CommandRunner +{ + public static int Invoke(RootCommand root, string[] args, StringWriter outWriter, StringWriter errWriter) + { + var parser = new Parser(root); + var origOut = Console.Out; var origErr = Console.Error; + Console.SetOut(outWriter); Console.SetError(errWriter); + try + { + var code = parser.Invoke(args); + if (Environment.ExitCode != 0 && code == 0) + { + code = Environment.ExitCode; + } + // Reset Environment.ExitCode to avoid leaking between tests + Environment.ExitCode = 0; + return code; + } + finally + { + Console.SetOut(origOut); Console.SetError(origErr); + } + } +} \ No newline at end of file diff --git a/dotnet/mcpb.Tests/ManifestDefaultsTests.cs b/dotnet/mcpb.Tests/ManifestDefaultsTests.cs new file mode 100644 index 0000000..9ebf5fb --- /dev/null +++ b/dotnet/mcpb.Tests/ManifestDefaultsTests.cs @@ -0,0 +1,44 @@ +using Mcpb.Core; +using Xunit; + +namespace Mcpb.Tests; + +public class ManifestDefaultsTests +{ + [Fact] + public void AutoDetect_Node() + { + using var dir = new TempDir(); + Directory.CreateDirectory(Path.Combine(dir.Path, "server")); + File.WriteAllText(Path.Combine(dir.Path, "package.json"), "{}" ); + File.WriteAllText(Path.Combine(dir.Path, "server","index.js"), "console.log('hi')"); + var m = ManifestDefaults.Create(dir.Path); + Assert.Equal("node", m.Server.Type); + Assert.Equal("server/index.js", m.Server.EntryPoint); + } + + [Fact] + public void AutoDetect_Python() + { + using var dir = new TempDir(); + Directory.CreateDirectory(Path.Combine(dir.Path, "server")); + File.WriteAllText(Path.Combine(dir.Path, "server","main.py"), "print('hi')"); + var m = ManifestDefaults.Create(dir.Path); + Assert.Equal("python", m.Server.Type); + } + + [Fact] + public void AutoDetect_Binary_Fallback() + { + using var dir = new TempDir(); + var m = ManifestDefaults.Create(dir.Path); + Assert.Equal("binary", m.Server.Type); + } + + private sealed class TempDir : IDisposable + { + public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "mcpbtest_" + Guid.NewGuid().ToString("N")); + public TempDir() { Directory.CreateDirectory(Path); } + public void Dispose() { try { Directory.Delete(Path, true); } catch { } } + } +} diff --git a/dotnet/mcpb.Tests/ManifestValidatorTests.cs b/dotnet/mcpb.Tests/ManifestValidatorTests.cs new file mode 100644 index 0000000..91f0cde --- /dev/null +++ b/dotnet/mcpb.Tests/ManifestValidatorTests.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using Mcpb.Core; +using Xunit; + +namespace Mcpb.Tests; + +public class ManifestValidatorTests +{ + private McpbManifest BaseManifest() => new() + { + ManifestVersion = "0.2", + Name = "test", + Version = "1.0.0", + Description = "desc", + Author = new McpbManifestAuthor { Name = "Author" }, + Server = new McpbManifestServer + { + Type = "binary", + EntryPoint = "server/test", + McpConfig = new McpServerConfigWithOverrides { Command = "${__dirname}/server/test" } + } + }; + + [Fact] + public void ValidManifest_Passes() + { + var m = BaseManifest(); + var issues = ManifestValidator.Validate(m); + Assert.Empty(issues); + } + + [Fact] + public void MissingRequiredFields_Fails() + { + var m = new McpbManifest(); // many missing + var issues = ManifestValidator.Validate(m); + // Because defaults populate most fields, only name should be missing + Assert.Single(issues); + Assert.Equal("name", issues[0].Path); + } + + [Fact] + public void ManifestVersionMissing_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = ""; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "manifest_version"); + } + + [Fact] + public void DxtVersionOnly_WarnsDeprecatedButPassesRequirement() + { + var m = BaseManifest(); + m.ManifestVersion = ""; // remove manifest_version + // set deprecated dxt_version via reflection (property exists) + m.GetType().GetProperty("DxtVersion")!.SetValue(m, "0.2"); + var issues = ManifestValidator.Validate(m); + Assert.DoesNotContain(issues, i => i.Path == "manifest_version"); + Assert.Contains(issues, i => i.Path == "dxt_version" && i.Message.Contains("deprecated")); + } + + [Fact] + public void NeitherVersionPresent_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = ""; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "manifest_version"); + } + + [Fact] + public void InvalidServerType_Fails() + { + var m = BaseManifest(); + m.Server.Type = "rust"; // unsupported + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "server.type"); + } + + [Fact] + public void InvalidVersionFormat_Fails() + { + var m = BaseManifest(); + m.Version = "1.0"; // not full semver + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "version"); + } + + [Fact] + public void PromptMissingText_ProducesWarning() + { + var m = BaseManifest(); + m.Prompts = new List { new() { Name = "dyn", Text = string.Empty } }; + var issues = ManifestValidator.Validate(m); + var warning = Assert.Single(issues, i => i.Path == "prompts[0].text"); + Assert.Equal(ValidationSeverity.Warning, warning.Severity); + } +} diff --git a/dotnet/mcpb.Tests/MetaFieldTests.cs b/dotnet/mcpb.Tests/MetaFieldTests.cs new file mode 100644 index 0000000..ccfd062 --- /dev/null +++ b/dotnet/mcpb.Tests/MetaFieldTests.cs @@ -0,0 +1,246 @@ +using System.Collections.Generic; +using System.Text.Json; +using Mcpb.Core; +using Mcpb.Json; +using Xunit; + +namespace Mcpb.Tests; + +public class MetaFieldTests +{ + [Fact] + public void Manifest_CanHaveMeta() + { + var manifest = new McpbManifest + { + ManifestVersion = "0.2", + Name = "test", + Version = "1.0.0", + Description = "Test manifest", + Author = new McpbManifestAuthor { Name = "Test Author" }, + Server = new McpbManifestServer + { + Type = "node", + EntryPoint = "server/index.js", + McpConfig = new McpServerConfigWithOverrides + { + Command = "node", + Args = new List { "server/index.js" } + } + }, + Meta = new Dictionary> + { + ["com.microsoft.windows"] = new Dictionary + { + ["package_family_name"] = "TestPackage_123", + ["channel"] = "stable" + } + } + }; + + var json = JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions); + Assert.Contains("\"_meta\"", json); + Assert.Contains("\"com.microsoft.windows\"", json); + Assert.Contains("\"package_family_name\"", json); + + var deserialized = JsonSerializer.Deserialize(json, McpbJsonContext.Default.McpbManifest); + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Meta); + Assert.True(deserialized.Meta.ContainsKey("com.microsoft.windows")); + } + + [Fact] + public void Manifest_MetaIsOptional() + { + var manifest = new McpbManifest + { + ManifestVersion = "0.2", + Name = "test", + Version = "1.0.0", + Description = "Test manifest", + Author = new McpbManifestAuthor { Name = "Test Author" }, + Server = new McpbManifestServer + { + Type = "node", + EntryPoint = "server/index.js", + McpConfig = new McpServerConfigWithOverrides + { + Command = "node", + Args = new List { "server/index.js" } + } + } + }; + + var json = JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions); + Assert.DoesNotContain("\"_meta\"", json); + } + + [Fact] + public void Manifest_CanDeserializeWithWindowsMeta() + { + var json = @"{ + ""manifest_version"": ""0.2"", + ""name"": ""test"", + ""version"": ""1.0.0"", + ""description"": ""Test manifest"", + ""author"": { ""name"": ""Test Author"" }, + ""server"": { + ""type"": ""node"", + ""entry_point"": ""server/index.js"", + ""mcp_config"": { + ""command"": ""node"", + ""args"": [""server/index.js""] + } + }, + ""_meta"": { + ""com.microsoft.windows"": { + ""static_responses"": { + ""initialize"": { + ""protocolVersion"": ""2025-06-18"", + ""serverInfo"": { + ""name"": ""test"", + ""version"": ""1.0.0"" + } + }, + ""tools/list"": { + ""tools"": [ + { + ""name"": ""tool1"", + ""description"": ""First tool"", + ""inputSchema"": { + ""type"": ""object"", + ""properties"": { + ""query"": { + ""type"": ""string"", + ""description"": ""Search query"" + } + } + }, + ""outputSchema"": { + ""type"": ""object"", + ""properties"": { + ""results"": { + ""type"": ""array"" + } + } + } + } + ] + } + } + } + } + }"; + + var manifest = JsonSerializer.Deserialize(json, McpbJsonContext.Default.McpbManifest); + Assert.NotNull(manifest); + Assert.NotNull(manifest.Meta); + Assert.True(manifest.Meta.ContainsKey("com.microsoft.windows")); + + // Verify we can extract the Windows meta + var windowsMeta = GetWindowsMetaFromManifest(manifest); + Assert.NotNull(windowsMeta); + Assert.NotNull(windowsMeta.StaticResponses); + Assert.NotNull(windowsMeta.StaticResponses.Initialize); + Assert.NotNull(windowsMeta.StaticResponses.ToolsList); + Assert.NotNull(windowsMeta.StaticResponses.ToolsList.Tools); + Assert.Single(windowsMeta.StaticResponses.ToolsList.Tools); + + // Verify the tool has the expected structure with inputSchema and outputSchema + var toolJson = JsonSerializer.Serialize(windowsMeta.StaticResponses.ToolsList.Tools[0]); + Assert.Contains("\"inputSchema\"", toolJson); + Assert.Contains("\"outputSchema\"", toolJson); + Assert.Contains("\"query\"", toolJson); + Assert.Contains("\"results\"", toolJson); + } + + [Fact] + public void StaticResponses_ContainInputAndOutputSchemas() + { + // This test verifies that tool schemas include both inputSchema and outputSchema + var initResult = new McpbInitializeResult + { + ProtocolVersion = "2025-06-18", + Capabilities = new { tools = new { listChanged = (object?)null } }, + ServerInfo = new { name = "test-server", version = "1.0.0" }, + Instructions = null + }; + + var toolsListResult = new McpbToolsListResult + { + Tools = new List + { + new + { + name = "search_tool", + description = "A search tool", + inputSchema = new + { + type = "object", + properties = new + { + query = new { type = "string", description = "Search query" }, + maxResults = new { type = "number", description = "Max results" } + }, + required = new[] { "query" } + }, + outputSchema = new + { + type = "object", + properties = new + { + results = new { type = "array" }, + count = new { type = "number" } + } + } + } + } + }; + + var staticResponses = new McpbStaticResponses + { + Initialize = initResult, + ToolsList = toolsListResult + }; + + // Serialize to verify structure + var json = JsonSerializer.Serialize(staticResponses, new JsonSerializerOptions { WriteIndented = true }); + + // Output to test logs for CI verification + Console.WriteLine("=== Static Responses Structure ==="); + Console.WriteLine(json); + Console.WriteLine("=== End Static Responses ==="); + + // Verify the JSON contains expected schemas + Assert.Contains("\"inputSchema\"", json); + Assert.Contains("\"outputSchema\"", json); + Assert.Contains("\"query\"", json); + Assert.Contains("\"maxResults\"", json); + Assert.Contains("\"results\"", json); + Assert.Contains("\"protocolVersion\"", json); + Assert.Contains("2025-06-18", json); + } + + private static McpbWindowsMeta? GetWindowsMetaFromManifest(McpbManifest manifest) + { + if (manifest.Meta == null || !manifest.Meta.TryGetValue("com.microsoft.windows", out var windowsMetaDict)) + { + return null; + } + + try + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + var json = JsonSerializer.Serialize(windowsMetaDict, options); + return JsonSerializer.Deserialize(json, options); + } + catch + { + return null; + } + } +} diff --git a/dotnet/mcpb.Tests/PathNormalizationTests.cs b/dotnet/mcpb.Tests/PathNormalizationTests.cs new file mode 100644 index 0000000..ff00c9d --- /dev/null +++ b/dotnet/mcpb.Tests/PathNormalizationTests.cs @@ -0,0 +1,44 @@ +using Xunit; +using System.IO; + +namespace Mcpb.Tests; + +public class PathNormalizationTests +{ + [Theory] + [InlineData("server/launch.js", "server")] + [InlineData("server\\launch.js", "server")] + [InlineData("subdir/nested\\script.py", "subdir")] // mixed separators + public void NormalizePath_RewritesSeparators(string raw, string expectedFirstSegment) + { + var norm = Mcpb.Commands.ManifestCommandHelpers.NormalizePathForPlatform(raw); + var sep = Path.DirectorySeparatorChar; + // Ensure we converted both kinds of slashes into the platform separator only + if (sep == '/') + { + Assert.DoesNotContain('\\', norm); + } + else + { + Assert.DoesNotContain('/', norm); + } + var first = norm.Split(sep)[0]; + Assert.Equal(expectedFirstSegment, first); + } + + [Fact] + public void NormalizePath_LeavesUrls() + { + var raw = "http://example.com/path/with/slash"; + var norm = Mcpb.Commands.ManifestCommandHelpers.NormalizePathForPlatform(raw); + Assert.Equal(raw, norm); // unchanged + } + + [Fact] + public void NormalizePath_LeavesFlags() + { + var raw = "--flag=value"; + var norm = Mcpb.Commands.ManifestCommandHelpers.NormalizePathForPlatform(raw); + Assert.Equal(raw, norm); + } +} diff --git a/dotnet/mcpb.Tests/SigningTests.cs b/dotnet/mcpb.Tests/SigningTests.cs new file mode 100644 index 0000000..4a1c982 --- /dev/null +++ b/dotnet/mcpb.Tests/SigningTests.cs @@ -0,0 +1,43 @@ +using Mcpb.Commands; +using System.Security.Cryptography; +using Xunit; + +namespace Mcpb.Tests; + +public class SigningTests +{ + [Fact] + public void SignAndVerify_RoundTrip() + { + // Prepare dummy bundle bytes + var content = System.Text.Encoding.UTF8.GetBytes("dummydata"); + var tmp = Path.Combine(Path.GetTempPath(), "mcpb_sign_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tmp); + var certPath = Path.Combine(tmp, "cert.pem"); + var keyPath = Path.Combine(tmp, "key.pem"); + + // Create self-signed cert using helper logic (mirror SignCommand) + using (var rsa = RSA.Create(2048)) + { + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest("CN=Test Cert", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + var certPem = cert.ExportCertificatePem(); + var keyPem = rsa.ExportPkcs8PrivateKeyPem(); + File.WriteAllText(certPath, certPem + Environment.NewLine); + File.WriteAllText(keyPath, keyPem + Environment.NewLine); + } + + var pkcs7 = SignatureHelpers.CreateDetachedPkcs7(content, certPath, keyPath); + Assert.NotNull(pkcs7); + var block = SignatureHelpers.CreateSignatureBlock(pkcs7); + var signed = content.Concat(block).ToArray(); + var (original, sig) = SignatureHelpers.ExtractSignatureBlock(signed); + Assert.NotNull(sig); + Assert.Equal(content, original); + var ok = SignatureHelpers.Verify(original, sig!, out var signerCert); + Assert.True(ok); + Assert.NotNull(signerCert); + + try { Directory.Delete(tmp, true); } catch { } + } +} diff --git a/dotnet/mcpb.Tests/mcpb.Tests.csproj b/dotnet/mcpb.Tests/mcpb.Tests.csproj new file mode 100644 index 0000000..f29707f --- /dev/null +++ b/dotnet/mcpb.Tests/mcpb.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0 + enable + enable + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/mcpb.slnx b/dotnet/mcpb.slnx new file mode 100644 index 0000000..47b27cb --- /dev/null +++ b/dotnet/mcpb.slnx @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/dotnet/mcpb/Commands/CliRoot.cs b/dotnet/mcpb/Commands/CliRoot.cs new file mode 100644 index 0000000..6fb25ef --- /dev/null +++ b/dotnet/mcpb/Commands/CliRoot.cs @@ -0,0 +1,20 @@ +using System.CommandLine; + +namespace Mcpb.Commands; + +public static class CliRoot +{ + public static RootCommand Build() + { + var root = new RootCommand("Tools for building MCP Bundles (.mcpb)"); + root.AddCommand(InitCommand.Create()); + root.AddCommand(ValidateCommand.Create()); + root.AddCommand(PackCommand.Create()); + root.AddCommand(UnpackCommand.Create()); + root.AddCommand(SignCommand.Create()); + root.AddCommand(VerifyCommand.Create()); + root.AddCommand(InfoCommand.Create()); + root.AddCommand(UnsignCommand.Create()); + return root; + } +} \ No newline at end of file diff --git a/dotnet/mcpb/Commands/InfoCommand.cs b/dotnet/mcpb/Commands/InfoCommand.cs new file mode 100644 index 0000000..e85df8d --- /dev/null +++ b/dotnet/mcpb/Commands/InfoCommand.cs @@ -0,0 +1,44 @@ +using System.CommandLine; +using System.Security.Cryptography.X509Certificates; + +namespace Mcpb.Commands; + +public static class InfoCommand +{ + public static Command Create() + { + var fileArg = new Argument("mcpb-file", "Path to .mcpb file"); + var cmd = new Command("info", "Display information about an MCPB file") { fileArg }; + cmd.SetHandler((string file)=> + { + var path = Path.GetFullPath(file); + if (!File.Exists(path)) { Console.Error.WriteLine($"ERROR: MCPB file not found: {file}"); return; } + try + { + var info = new FileInfo(path); + Console.WriteLine($"File: {info.Name}"); + Console.WriteLine($"Size: {info.Length/1024.0:F2} KB"); + var bytes = File.ReadAllBytes(path); + var (original, sig) = SignatureHelpers.ExtractSignatureBlock(bytes); + if (sig != null && SignatureHelpers.Verify(original, sig, out var cert) && cert != null) + { + Console.WriteLine("\nSignature Information:"); + Console.WriteLine($" Subject: {cert.Subject}"); + Console.WriteLine($" Issuer: {cert.Issuer}"); + Console.WriteLine($" Valid from: {cert.NotBefore:MM/dd/yyyy} to {cert.NotAfter:MM/dd/yyyy}"); + Console.WriteLine($" Fingerprint: {cert.Thumbprint}"); + Console.WriteLine($" Status: Valid"); + } + else + { + Console.WriteLine("\nWARNING: Not signed"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"ERROR: Failed to read MCPB info: {ex.Message}"); + } + }, fileArg); + return cmd; + } +} diff --git a/dotnet/mcpb/Commands/InitCommand.cs b/dotnet/mcpb/Commands/InitCommand.cs new file mode 100644 index 0000000..8dbf618 --- /dev/null +++ b/dotnet/mcpb/Commands/InitCommand.cs @@ -0,0 +1,420 @@ +using System.CommandLine; +using Mcpb.Core; +using System.Text.Json; +using Mcpb.Json; +using System.Text.RegularExpressions; + +namespace Mcpb.Commands; + +public static class InitCommand +{ + public const string LatestMcpbSchemaVersion = "0.2"; // Latest schema version + + public static Command Create() + { + var directoryArg = new Argument("directory", () => Directory.GetCurrentDirectory(), "Target directory"); + var yesOption = new Option(new[] { "--yes", "-y" }, "Accept defaults (non-interactive)"); + var serverTypeOpt = new Option("--server-type", () => "auto", "Server type: node|python|binary|auto"); + var entryPointOpt = new Option("--entry-point", description: "Override entry point (relative to manifest)"); + var cmd = new Command("init", "Create a new MCPB extension manifest") { directoryArg, yesOption, serverTypeOpt, entryPointOpt }; + cmd.SetHandler(async (string? dir, bool yes, string serverTypeOptValue, string? entryPointOverride) => + { + var targetDir = Path.GetFullPath(dir ?? Directory.GetCurrentDirectory()); + Directory.CreateDirectory(targetDir); + var manifestPath = Path.Combine(targetDir, "manifest.json"); + + if (File.Exists(manifestPath) && !yes) + { + if (!PromptConfirm("manifest.json already exists. Overwrite?", false)) + { + Console.WriteLine("Cancelled"); + return; + } + } + + if (!yes) + { + Console.WriteLine("This utility will help you create a manifest.json file for your MCPB bundle."); + Console.WriteLine("Press Ctrl+C at any time to quit.\n"); + } + else + { + Console.WriteLine("Creating manifest.json with default values..."); + } + + // Package.json style defaults (simplified: we look for package.json for name/version/description/author fields if present) + var pkg = PackageJson.TryLoad(targetDir); + + // Basic info + string name, authorName, displayName, version, description; + if (yes) + { + name = pkg.Name ?? new DirectoryInfo(targetDir).Name; + authorName = pkg.AuthorName ?? "Unknown Author"; + displayName = name; + version = pkg.Version ?? "1.0.0"; + description = pkg.Description ?? "A MCPB bundle"; + } + else + { + name = PromptRequired("Extension name:", pkg.Name ?? new DirectoryInfo(targetDir).Name); + authorName = PromptRequired("Author name:", pkg.AuthorName ?? "Unknown Author"); + displayName = Prompt("Display name (optional):", name); + version = PromptValidated("Version:", pkg.Version ?? "1.0.0", s => Regex.IsMatch(s, "^\\d+\\.\\d+\\.\\d+") ? null : "Version must follow semantic versioning (e.g., 1.0.0)"); + description = PromptRequired("Description:", pkg.Description ?? ""); + } + + // Long description + string? longDescription = null; + if (!yes && PromptConfirm("Add a detailed long description?", false)) + { + longDescription = Prompt("Long description (supports basic markdown):", description); + } + + // Author extras + string authorEmail = yes ? (pkg.AuthorEmail ?? "") : Prompt("Author email (optional):", pkg.AuthorEmail ?? ""); + string authorUrl = yes ? (pkg.AuthorUrl ?? "") : Prompt("Author URL (optional):", pkg.AuthorUrl ?? ""); + + // Server type + string serverType = "binary"; // default differs from TS (binary for .NET) + if (!yes) + { + serverType = PromptSelect("Server type:", new[] { "node", "python", "binary" }, "binary"); + } + else if (!string.IsNullOrWhiteSpace(serverTypeOptValue) && serverTypeOptValue is "node" or "python" or "binary") + { + serverType = serverTypeOptValue; + } + + string entryPoint = entryPointOverride ?? GetDefaultEntryPoint(serverType, pkg.Main); + if (!yes) + { + entryPoint = Prompt("Entry point:", entryPoint); + } + + var mcpConfig = CreateMcpConfig(serverType, entryPoint); + + // Tools + var tools = new List(); + bool toolsGenerated = false; + if (!yes && PromptConfirm("Does your MCP Server provide tools you want to advertise (optional)?", true)) + { + bool addMore; + do + { + var tName = PromptRequired("Tool name:", ""); + var tDesc = Prompt("Tool description (optional):", ""); + tools.Add(new McpbManifestTool { Name = tName, Description = string.IsNullOrWhiteSpace(tDesc) ? null : tDesc }); + addMore = PromptConfirm("Add another tool?", false); + } while (addMore); + toolsGenerated = PromptConfirm("Does your server generate additional tools at runtime?", false); + } + + // Prompts + var prompts = new List(); + bool promptsGenerated = false; + if (!yes && PromptConfirm("Does your MCP Server provide prompts you want to advertise (optional)?", false)) + { + bool addMore; + do + { + var pName = PromptRequired("Prompt name:", ""); + var pDesc = Prompt("Prompt description (optional):", ""); + var hasArgs = PromptConfirm("Does this prompt have arguments?", false); + List? argsList = null; + if (hasArgs) + { + argsList = new(); + bool addArg; + do + { + var aName = PromptValidated("Argument name:", "", v => string.IsNullOrWhiteSpace(v) ? "Argument name is required" : (argsList.Contains(v) ? "Argument names must be unique" : null)); + argsList.Add(aName); + addArg = PromptConfirm("Add another argument?", false); + } while (addArg); + } + var promptTextMsg = hasArgs ? $"Prompt text (use ${{arguments.name}} for arguments: {string.Join(", ", argsList ?? new())}):" : "Prompt text:"; + var pText = PromptRequired(promptTextMsg, ""); + prompts.Add(new McpbManifestPrompt { Name = pName, Description = string.IsNullOrWhiteSpace(pDesc) ? null : pDesc, Arguments = argsList, Text = pText }); + addMore = PromptConfirm("Add another prompt?", false); + } while (addMore); + promptsGenerated = PromptConfirm("Does your server generate additional prompts at runtime?", false); + } + + // Optional URLs + string homepage = yes ? "" : PromptUrl("Homepage URL (optional):"); + string documentation = yes ? "" : PromptUrl("Documentation URL (optional):"); + string support = yes ? "" : PromptUrl("Support URL (optional):"); + + // Visual assets + string icon = ""; + List screenshots = new(); + if (!yes) + { + icon = PromptPathOptional("Icon file path (optional, relative to manifest):"); + if (PromptConfirm("Add screenshots?", false)) + { + bool addShot; + do + { + var shot = PromptValidated("Screenshot file path (relative to manifest):", "", v => string.IsNullOrWhiteSpace(v) ? "Screenshot path is required" : (v.Contains("..") ? "Relative paths cannot include '..'" : null)); + screenshots.Add(shot); + addShot = PromptConfirm("Add another screenshot?", false); + } while (addShot); + } + } + + // Compatibility + McpbManifestCompatibility? compatibility = null; + if (!yes && PromptConfirm("Add compatibility constraints?", false)) + { + List? platforms = null; + if (PromptConfirm("Specify supported platforms?", false)) + { + platforms = new List(); + if (PromptConfirm("Support macOS (darwin)?", true)) platforms.Add("darwin"); + if (PromptConfirm("Support Windows (win32)?", true)) platforms.Add("win32"); + if (PromptConfirm("Support Linux?", true)) platforms.Add("linux"); + if (platforms.Count == 0) platforms = null; + } + McpbManifestCompatibilityRuntimes? runtimes = null; + if (serverType != "binary" && PromptConfirm("Specify runtime version constraints?", false)) + { + runtimes = new McpbManifestCompatibilityRuntimes(); + if (serverType == "python") + runtimes.Python = PromptRequired("Python version constraint (e.g., >=3.8,<4.0):", ""); + else if (serverType == "node") + runtimes.Node = PromptRequired("Node.js version constraint (e.g., >=16.0.0):", ""); + } + compatibility = new McpbManifestCompatibility { Platforms = platforms, Runtimes = runtimes }; + } + + // user_config + Dictionary? userConfig = null; + if (!yes && PromptConfirm("Add user-configurable options?", false)) + { + userConfig = new(); + bool addOpt; + do + { + var key = PromptValidated("Configuration option key (unique identifier):", "", v => string.IsNullOrWhiteSpace(v) ? "Key is required" : (userConfig.ContainsKey(v) ? "Key must be unique" : null)); + var type = PromptSelect("Option type:", new[] { "string", "number", "boolean", "directory", "file" }, "string"); + var title = PromptRequired("Option title (human-readable name):", ""); + var desc = PromptRequired("Option description:", ""); + var required = PromptConfirm("Is this option required?", false); + var sensitive = PromptConfirm("Is this option sensitive (like a password)?", false); + var opt = new McpbUserConfigOption { Type = type, Title = title, Description = desc, Sensitive = sensitive, Required = required }; + if (!required) + { + if (type == "boolean") + { + opt.Default = PromptConfirm("Default value:", false); + } + else if (type == "number") + { + var defStr = Prompt("Default value (number, optional):", ""); + if (double.TryParse(defStr, out var defVal)) opt.Default = defVal; + } + else + { + var defVal = Prompt("Default value (optional):", ""); + if (!string.IsNullOrWhiteSpace(defVal)) opt.Default = defVal; + } + } + if (type == "number" && PromptConfirm("Add min/max constraints?", false)) + { + var minStr = Prompt("Minimum value (optional):", ""); + if (double.TryParse(minStr, out var minVal)) opt.Min = minVal; + var maxStr = Prompt("Maximum value (optional):", ""); + if (double.TryParse(maxStr, out var maxVal)) opt.Max = maxVal; + } + userConfig[key] = opt; + addOpt = PromptConfirm("Add another configuration option?", false); + } while (addOpt); + } + + // Optional fields (keywords, license, repo) + string keywordsCsv = yes ? "" : Prompt("Keywords (comma-separated, optional):", ""); + string license = yes ? (pkg.License ?? "MIT") : Prompt("License:", pkg.License ?? "MIT"); + McpbManifestRepository? repository = null; + if (!yes && PromptConfirm("Add repository information?", !string.IsNullOrWhiteSpace(pkg.RepositoryUrl))) + { + var repoUrl = Prompt("Repository URL:", pkg.RepositoryUrl ?? ""); + if (!string.IsNullOrWhiteSpace(repoUrl)) repository = new McpbManifestRepository { Type = "git", Url = repoUrl }; + } + else if (yes && !string.IsNullOrWhiteSpace(pkg.RepositoryUrl)) + { + repository = new McpbManifestRepository { Type = "git", Url = pkg.RepositoryUrl! }; + } + + var manifest = new McpbManifest + { + ManifestVersion = LatestMcpbSchemaVersion, + Name = name, + DisplayName = displayName != name ? displayName : null, + Version = version, + Description = description, + LongDescription = longDescription, + Author = new McpbManifestAuthor { Name = authorName, Email = string.IsNullOrWhiteSpace(authorEmail) ? null : authorEmail, Url = string.IsNullOrWhiteSpace(authorUrl) ? null : authorUrl }, + Homepage = string.IsNullOrWhiteSpace(homepage) ? null : homepage, + Documentation = string.IsNullOrWhiteSpace(documentation) ? null : documentation, + Support = string.IsNullOrWhiteSpace(support) ? null : support, + Icon = string.IsNullOrWhiteSpace(icon) ? null : icon, + Screenshots = screenshots.Count > 0 ? screenshots : null, + Server = new McpbManifestServer { Type = serverType, EntryPoint = entryPoint, McpConfig = mcpConfig }, + Tools = tools.Count > 0 ? tools : null, + ToolsGenerated = toolsGenerated ? true : null, + Prompts = prompts.Count > 0 ? prompts : null, + PromptsGenerated = promptsGenerated ? true : null, + Compatibility = compatibility, + UserConfig = userConfig, + Keywords = string.IsNullOrWhiteSpace(keywordsCsv) ? null : keywordsCsv.Split(',').Select(k => k.Trim()).Where(k => k.Length > 0).ToList(), + License = string.IsNullOrWhiteSpace(license) ? null : license, + Repository = repository + }; + + var json = JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions); + await File.WriteAllTextAsync(manifestPath, json + "\n"); + Console.WriteLine($"\nCreated manifest.json at {manifestPath}"); + Console.WriteLine("\nNext steps:"); + Console.WriteLine("1. Ensure all your production dependencies are in this directory"); + Console.WriteLine("2. Run 'mcpb pack' to create your .mcpb file"); + }, directoryArg, yesOption, serverTypeOpt, entryPointOpt); + return cmd; + } + + #region Prompt Helpers + private static bool PromptConfirm(string message, bool defaultValue) + { + Console.Write(message + (defaultValue ? " (Y/n): " : " (y/N): ")); + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) return defaultValue; + input = input.Trim().ToLowerInvariant(); + return input is "y" or "yes"; + } + private static string Prompt(string message, string defaultValue) + { + Console.Write(string.IsNullOrEmpty(defaultValue) ? message + " " : message + " [" + defaultValue + "]: "); + var input = Console.ReadLine(); + return string.IsNullOrWhiteSpace(input) ? defaultValue : input.Trim(); + } + private static string PromptRequired(string message, string defaultValue) + { + while (true) + { + var v = Prompt(message, defaultValue); + if (!string.IsNullOrWhiteSpace(v)) return v; + Console.WriteLine(" Value is required"); + } + } + private static string PromptValidated(string message, string defaultValue, Func validator) + { + while (true) + { + var v = Prompt(message, defaultValue); + var err = validator(v); + if (err == null) return v; + Console.WriteLine(" " + err); + } + } + private static string PromptSelect(string message, string[] options, string defaultValue) + { + Console.WriteLine(message + " " + string.Join("/", options.Select(o => o == defaultValue ? $"[{o}]" : o)) + ": "); + while (true) + { + var inp = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(inp)) return defaultValue; + inp = inp.Trim().ToLowerInvariant(); + if (options.Contains(inp)) return inp; + Console.WriteLine(" Invalid choice"); + } + } + private static string PromptUrl(string message) + { + while (true) + { + Console.Write(message + " "); + var inp = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(inp)) return ""; + if (Uri.TryCreate(inp, UriKind.Absolute, out _)) return inp.Trim(); + Console.WriteLine(" Must be a valid URL (e.g., https://example.com)"); + } + } + private static string PromptPathOptional(string message) + { + while (true) + { + Console.Write(message + " "); + var inp = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(inp)) return ""; + if (inp.Contains("..")) { Console.WriteLine(" Relative paths cannot include '..'"); continue; } + return inp.Trim(); + } + } + + private static string GetDefaultEntryPoint(string serverType, string? pkgMain) => serverType switch + { + "node" => string.IsNullOrWhiteSpace(pkgMain) ? "server/index.js" : pkgMain!, + "python" => "server/main.py", + _ => OperatingSystem.IsWindows() ? "server/my-server.exe" : "server/my-server", + }; + private static McpServerConfigWithOverrides CreateMcpConfig(string serverType, string entryPoint) + { + return new McpServerConfigWithOverrides + { + Command = serverType switch + { + "node" => "node", + "python" => "python", + _ => "${__dirname}/" + entryPoint + }, + Args = serverType switch + { + "node" => new List { "${__dirname}/" + entryPoint }, + "python" => new List { "${__dirname}/" + entryPoint }, + _ => new List() + }, + Env = serverType switch + { + "python" => new Dictionary { { "PYTHONPATH", "${__dirname}/server/lib" } }, + _ => new Dictionary() + } + }; + } + + // Minimal package.json probing + private record PackageProbe(string? Name, string? Version, string? Description, string? AuthorName, string? AuthorEmail, string? AuthorUrl, string? Main, string? License, string? RepositoryUrl); + private static class PackageJson + { + public static PackageProbe TryLoad(string dir) + { + try + { + var file = Path.Combine(dir, "package.json"); + if (!File.Exists(file)) return new PackageProbe(null, null, null, null, null, null, null, null, null); + using var doc = JsonDocument.Parse(File.ReadAllText(file)); + string? Get(params string[] path) + { + JsonElement cur = doc.RootElement; + foreach (var p in path) + { + if (cur.ValueKind == JsonValueKind.Object && cur.TryGetProperty(p, out var next)) cur = next; else return null; + } + return cur.ValueKind switch { JsonValueKind.String => cur.GetString(), _ => null }; + } + var name = Get("name"); + var version = Get("version"); + var description = Get("description"); + var main = Get("main"); + var authorName = Get("author", "name") ?? Get("author"); + var authorEmail = Get("author", "email"); + var authorUrl = Get("author", "url"); + var license = Get("license"); + var repoUrl = Get("repository", "url") ?? Get("repository"); + return new PackageProbe(name, version, description, authorName, authorEmail, authorUrl, main, license, repoUrl); + } + catch { return new PackageProbe(null, null, null, null, null, null, null, null, null); } + } + } + #endregion +} \ No newline at end of file diff --git a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs new file mode 100644 index 0000000..36c1c3c --- /dev/null +++ b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs @@ -0,0 +1,723 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Mcpb.Core; +using Mcpb.Json; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Mcpb.Commands; + +internal static class ManifestCommandHelpers +{ + internal record CapabilityDiscoveryResult( + List Tools, + List Prompts, + McpbInitializeResult? InitializeResponse, + McpbToolsListResult? ToolsListResponse); + + /// + /// Recursively filters out null properties from a JsonElement to match JsonIgnoreCondition.WhenWritingNull behavior + /// + private static object FilterNullProperties(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var dict = new Dictionary(); + foreach (var property in element.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.Null) + { + dict[property.Name] = FilterNullProperties(property.Value); + } + } + return dict; + } + else if (element.ValueKind == JsonValueKind.Array) + { + var list = new List(); + foreach (var item in element.EnumerateArray()) + { + list.Add(FilterNullProperties(item)); + } + return list; + } + else if (element.ValueKind == JsonValueKind.String) + { + return element.GetString() ?? ""; + } + else if (element.ValueKind == JsonValueKind.Number) + { + if (element.TryGetInt64(out var longValue)) + return longValue; + return element.GetDouble(); + } + else if (element.ValueKind == JsonValueKind.True) + { + return true; + } + else if (element.ValueKind == JsonValueKind.False) + { + return false; + } + else + { + // For other types, convert to JsonElement + var json = JsonSerializer.Serialize(element); + return JsonSerializer.Deserialize(json); + } + } + + internal static List ValidateReferencedFiles(McpbManifest manifest, string baseDir) + { + var errors = new List(); + if (manifest.Server == null) + { + errors.Add("Manifest server configuration missing"); + return errors; + } + + string Resolve(string rel) + { + var normalized = rel.Replace('\\', '/'); + if (Path.IsPathRooted(normalized)) + { + return normalized.Replace('/', Path.DirectorySeparatorChar); + } + return Path.Combine(baseDir, normalized.Replace('/', Path.DirectorySeparatorChar)); + } + + void CheckFile(string? relativePath, string category) + { + if (string.IsNullOrWhiteSpace(relativePath)) return; + var resolved = Resolve(relativePath); + if (!File.Exists(resolved)) + { + errors.Add($"Missing {category} file: {relativePath}"); + } + } + + if (!string.IsNullOrWhiteSpace(manifest.Icon)) + { + CheckFile(manifest.Icon, "icon"); + } + + if (!string.IsNullOrWhiteSpace(manifest.Server.EntryPoint)) + { + CheckFile(manifest.Server.EntryPoint, "entry_point"); + } + + var command = manifest.Server.McpConfig?.Command; + if (!string.IsNullOrWhiteSpace(command)) + { + var cmd = command!; + 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); + if (pathLike) + { + var expanded = ExpandToken(cmd, baseDir); + var normalized = NormalizePathForPlatform(expanded); + var resolved = normalized; + if (!Path.IsPathRooted(normalized)) + { + resolved = Path.Combine(baseDir, normalized); + } + if (!File.Exists(resolved)) + { + errors.Add($"Missing server.command file: {command}"); + } + } + } + + if (manifest.Screenshots != null) + { + foreach (var shot in manifest.Screenshots) + { + if (string.IsNullOrWhiteSpace(shot)) continue; + CheckFile(shot, "screenshot"); + } + } + + return errors; + } + + internal static async Task DiscoverCapabilitiesAsync( + string dir, + McpbManifest manifest, + Action? logInfo, + Action? logWarning) + { + var overrideTools = TryParseToolOverride("MCPB_TOOL_DISCOVERY_JSON"); + var overridePrompts = TryParsePromptOverride("MCPB_PROMPT_DISCOVERY_JSON"); + if (overrideTools != null || overridePrompts != null) + { + return new CapabilityDiscoveryResult( + overrideTools ?? new List(), + overridePrompts ?? new List(), + null, + null); + } + + var cfg = manifest.Server?.McpConfig ?? throw new InvalidOperationException("Manifest server.mcp_config missing"); + var command = cfg.Command; + if (string.IsNullOrWhiteSpace(command)) throw new InvalidOperationException("Manifest server.mcp_config.command empty"); + var rawArgs = cfg.Args ?? new List(); + command = ExpandToken(command, dir); + var args = rawArgs.Select(a => ExpandToken(a, dir)).Where(a => !string.IsNullOrWhiteSpace(a)).ToList(); + command = NormalizePathForPlatform(command); + for (int i = 0; i < args.Count; i++) args[i] = NormalizePathForPlatform(args[i]); + + Dictionary? env = null; + if (cfg.Env != null && cfg.Env.Count > 0) + { + env = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in cfg.Env) + { + var expanded = ExpandToken(kv.Value, dir); + env[kv.Key] = NormalizePathForPlatform(expanded); + } + } + + var toolInfos = new List(); + var promptInfos = new List(); + McpbInitializeResult? initializeResponse = null; + McpbToolsListResult? toolsListResponse = null; + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + IDictionary? envVars = null; + if (env != null) + { + envVars = new Dictionary(env.ToDictionary(kv => kv.Key, kv => (string?)kv.Value), StringComparer.OrdinalIgnoreCase); + } + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "mcpb-discovery", + Command = command, + Arguments = args.ToArray(), + WorkingDirectory = dir, + EnvironmentVariables = envVars + }); + logInfo?.Invoke($"Discovering tools & prompts using: {command} {string.Join(' ', args)}"); + await using var client = await McpClient.CreateAsync(transport); + + // Capture initialize response using McpClient properties + // Filter out null properties to match JsonIgnoreCondition.WhenWritingNull behavior + try + { + // Serialize and filter capabilities + object? capabilities = null; + if (client.ServerCapabilities != null) + { + var capJson = JsonSerializer.Serialize(client.ServerCapabilities); + var capElement = JsonSerializer.Deserialize(capJson); + capabilities = FilterNullProperties(capElement); + } + + // Serialize and filter serverInfo + object? serverInfo = null; + if (client.ServerInfo != null) + { + var infoJson = JsonSerializer.Serialize(client.ServerInfo); + var infoElement = JsonSerializer.Deserialize(infoJson); + serverInfo = FilterNullProperties(infoElement); + } + + var instructions = string.IsNullOrWhiteSpace(client.ServerInstructions) ? null : client.ServerInstructions; + + initializeResponse = new McpbInitializeResult + { + ProtocolVersion = client.NegotiatedProtocolVersion, + Capabilities = capabilities, + ServerInfo = serverInfo, + Instructions = instructions + }; + } + catch (Exception ex) + { + logWarning?.Invoke($"Failed to capture initialize response: {ex.Message}"); + } + + var tools = await client.ListToolsAsync(null, cts.Token); + + // Capture tools/list response using typed Tool objects + // Filter out null properties to match JsonIgnoreCondition.WhenWritingNull behavior + try + { + var toolsList = new List(); + foreach (var tool in tools) + { + // Serialize the tool and parse to JsonElement + var json = JsonSerializer.Serialize(tool.ProtocolTool); + var element = JsonSerializer.Deserialize(json); + + // Filter out null properties recursively + var filtered = FilterNullProperties(element); + toolsList.Add(filtered); + } + toolsListResponse = new McpbToolsListResult { Tools = toolsList }; + } + catch (Exception ex) + { + logWarning?.Invoke($"Failed to capture tools/list response: {ex.Message}"); + } + + foreach (var tool in tools) + { + if (string.IsNullOrWhiteSpace(tool.Name)) continue; + var manifestTool = new McpbManifestTool + { + Name = tool.Name, + Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description + }; + toolInfos.Add(manifestTool); + } + try + { + var prompts = await client.ListPromptsAsync(cts.Token); + foreach (var prompt in prompts) + { + if (string.IsNullOrWhiteSpace(prompt.Name)) continue; + var manifestPrompt = new McpbManifestPrompt + { + Name = prompt.Name, + Description = string.IsNullOrWhiteSpace(prompt.Description) ? null : prompt.Description, + Arguments = prompt.ProtocolPrompt?.Arguments? + .Select(a => a.Name) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Distinct(StringComparer.Ordinal) + .ToList() + }; + if (manifestPrompt.Arguments != null && manifestPrompt.Arguments.Count == 0) + { + manifestPrompt.Arguments = null; + } + try + { + var promptResult = await client.GetPromptAsync(prompt.Name, cancellationToken: cts.Token); + manifestPrompt.Text = ExtractPromptText(promptResult); + } + catch (Exception ex) + { + logWarning?.Invoke($"Prompt '{prompt.Name}' content fetch failed: {ex.Message}"); + manifestPrompt.Text = string.Empty; + } + promptInfos.Add(manifestPrompt); + } + } + catch (Exception ex) + { + logWarning?.Invoke($"Prompt discovery skipped: {ex.Message}"); + } + } + catch (Exception ex) + { + logWarning?.Invoke($"MCP client discovery failed: {ex.Message}"); + } + + return new CapabilityDiscoveryResult( + DeduplicateTools(toolInfos), + DeduplicatePrompts(promptInfos), + initializeResponse, + toolsListResponse); + } + + internal static string NormalizePathForPlatform(string value) + { + if (string.IsNullOrEmpty(value)) return value; + if (value.Contains("://")) return value; + if (value.StartsWith("-")) return value; + var sep = Path.DirectorySeparatorChar; + return value.Replace('\\', sep).Replace('/', sep); + } + + internal static string ExpandToken(string value, string dir) + { + if (string.IsNullOrEmpty(value)) return value; + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string desktop = SafeGetSpecial(Environment.SpecialFolder.Desktop, Path.Combine(home, "Desktop")); + string documents = SafeGetSpecial(Environment.SpecialFolder.MyDocuments, Path.Combine(home, "Documents")); + string downloads = Path.Combine(home, "Downloads"); + string sep = Path.DirectorySeparatorChar.ToString(); + return Regex.Replace(value, "\\$\\{([^}]+)\\}", m => + { + var token = m.Groups[1].Value; + if (string.Equals(token, "__dirname", StringComparison.OrdinalIgnoreCase)) return dir.Replace('\\', '/'); + if (string.Equals(token, "HOME", StringComparison.OrdinalIgnoreCase)) return home; + if (string.Equals(token, "DESKTOP", StringComparison.OrdinalIgnoreCase)) return desktop; + if (string.Equals(token, "DOCUMENTS", StringComparison.OrdinalIgnoreCase)) return documents; + if (string.Equals(token, "DOWNLOADS", StringComparison.OrdinalIgnoreCase)) return downloads; + if (string.Equals(token, "pathSeparator", StringComparison.OrdinalIgnoreCase) || token == "/") return sep; + if (token.StartsWith("user_config.", StringComparison.OrdinalIgnoreCase)) return string.Empty; + return m.Value; + }); + } + + private static string SafeGetSpecial(Environment.SpecialFolder folder, string fallback) + { + try { var p = Environment.GetFolderPath(folder); return string.IsNullOrEmpty(p) ? fallback : p; } + catch { return fallback; } + } + + private static List? TryParseToolOverride(string envVar) + { + var json = Environment.GetEnvironmentVariable(envVar); + if (string.IsNullOrWhiteSpace(json)) return null; + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return null; + var list = new List(); + foreach (var el in doc.RootElement.EnumerateArray()) + { + if (el.ValueKind == JsonValueKind.String) + { + var name = el.GetString(); + if (!string.IsNullOrWhiteSpace(name)) + { + list.Add(new McpbManifestTool { Name = name! }); + } + continue; + } + + if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty("name", out var nameProp) || nameProp.ValueKind != JsonValueKind.String) + { + continue; + } + + var tool = new McpbManifestTool + { + Name = nameProp.GetString() ?? string.Empty + }; + + if (el.TryGetProperty("description", out var descProp) && descProp.ValueKind == JsonValueKind.String) + { + var desc = descProp.GetString(); + tool.Description = string.IsNullOrWhiteSpace(desc) ? null : desc; + } + + list.Add(tool); + } + + return list.Count == 0 ? null : DeduplicateTools(list); + } + catch + { + return null; + } + } + + private static List? TryParsePromptOverride(string envVar) + { + var json = Environment.GetEnvironmentVariable(envVar); + if (string.IsNullOrWhiteSpace(json)) return null; + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return null; + var list = new List(); + foreach (var el in doc.RootElement.EnumerateArray()) + { + if (el.ValueKind == JsonValueKind.String) + { + var name = el.GetString(); + if (!string.IsNullOrWhiteSpace(name)) list.Add(new McpbManifestPrompt { Name = name!, Text = string.Empty }); + continue; + } + + if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty("name", out var nameProp) || nameProp.ValueKind != JsonValueKind.String) + { + continue; + } + + var prompt = new McpbManifestPrompt + { + Name = nameProp.GetString() ?? string.Empty, + Text = string.Empty + }; + + if (el.TryGetProperty("description", out var descProp) && descProp.ValueKind == JsonValueKind.String) + { + var desc = descProp.GetString(); + prompt.Description = string.IsNullOrWhiteSpace(desc) ? null : desc; + } + + if (el.TryGetProperty("arguments", out var argsProp) && argsProp.ValueKind == JsonValueKind.Array) + { + var args = new List(); + foreach (var arg in argsProp.EnumerateArray()) + { + if (arg.ValueKind == JsonValueKind.String) + { + var argName = arg.GetString(); + if (!string.IsNullOrWhiteSpace(argName)) args.Add(argName!); + } + } + prompt.Arguments = args.Count > 0 ? args : null; + } + + if (el.TryGetProperty("text", out var textProp) && textProp.ValueKind == JsonValueKind.String) + { + prompt.Text = textProp.GetString() ?? string.Empty; + } + + list.Add(prompt); + } + + return list.Count == 0 ? null : DeduplicatePrompts(list); + } + catch + { + return null; + } + } + + private static List DeduplicateTools(IEnumerable tools) + { + return tools + .Where(t => !string.IsNullOrWhiteSpace(t.Name)) + .GroupBy(t => t.Name, StringComparer.Ordinal) + .Select(g => MergeToolGroup(g)) + .OrderBy(t => t.Name, StringComparer.Ordinal) + .ToList(); + } + + private static McpbManifestTool MergeToolGroup(IEnumerable group) + { + var first = group.First(); + if (!string.IsNullOrWhiteSpace(first.Description)) return first; + var description = group.Select(t => t.Description).FirstOrDefault(d => !string.IsNullOrWhiteSpace(d)); + return new McpbManifestTool + { + Name = first.Name, + Description = string.IsNullOrWhiteSpace(description) ? null : description + }; + } + + private static List DeduplicatePrompts(IEnumerable prompts) + { + return prompts + .Where(p => !string.IsNullOrWhiteSpace(p.Name)) + .GroupBy(p => p.Name, StringComparer.Ordinal) + .Select(g => MergePromptGroup(g)) + .OrderBy(p => p.Name, StringComparer.Ordinal) + .ToList(); + } + + private static McpbManifestPrompt MergePromptGroup(IEnumerable group) + { + var first = group.First(); + var description = !string.IsNullOrWhiteSpace(first.Description) + ? first.Description + : group.Select(p => p.Description).FirstOrDefault(d => !string.IsNullOrWhiteSpace(d)); + var aggregatedArgs = first.Arguments != null && first.Arguments.Count > 0 + ? new List(first.Arguments) + : group.SelectMany(p => p.Arguments ?? new List()).Distinct(StringComparer.Ordinal).ToList(); + + var text = !string.IsNullOrWhiteSpace(first.Text) + ? first.Text + : group.Select(p => p.Text).FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? string.Empty; + + return new McpbManifestPrompt + { + Name = first.Name, + Description = string.IsNullOrWhiteSpace(description) ? null : description, + Arguments = aggregatedArgs.Count > 0 ? aggregatedArgs : null, + Text = text + }; + } + + private static string ExtractPromptText(GetPromptResult? promptResult) + { + if (promptResult?.Messages == null) return string.Empty; + var builder = new StringBuilder(); + foreach (var message in promptResult.Messages) + { + if (message?.Content == null) continue; + AppendContentBlocks(builder, message.Content); + } + return builder.ToString(); + } + + private static void AppendContentBlocks(StringBuilder builder, object content) + { + switch (content) + { + case null: + return; + case TextContentBlock textBlock: + AppendText(builder, textBlock); + return; + case IEnumerable enumerableBlocks: + foreach (var block in enumerableBlocks) + { + AppendText(builder, block as TextContentBlock); + } + return; + case ContentBlock singleBlock: + AppendText(builder, singleBlock as TextContentBlock); + return; + } + } + + private static void AppendText(StringBuilder builder, TextContentBlock? textBlock) + { + if (textBlock == null || string.IsNullOrWhiteSpace(textBlock.Text)) return; + if (builder.Length > 0) builder.AppendLine(); + builder.Append(textBlock.Text); + } + + internal static List GetToolMetadataDifferences(IEnumerable? manifestTools, IEnumerable discoveredTools) + { + var differences = new List(); + if (manifestTools == null) return differences; + var manifestByName = manifestTools + .Where(t => !string.IsNullOrWhiteSpace(t.Name)) + .ToDictionary(t => t.Name, StringComparer.Ordinal); + + foreach (var tool in discoveredTools) + { + if (string.IsNullOrWhiteSpace(tool.Name)) continue; + if (!manifestByName.TryGetValue(tool.Name, out var existing)) continue; + + if (!StringEqualsNormalized(existing.Description, tool.Description)) + { + differences.Add($"Tool '{tool.Name}' description differs (manifest: {FormatValue(existing.Description)}, discovered: {FormatValue(tool.Description)})."); + } + } + + return differences; + } + + internal static List GetPromptMetadataDifferences(IEnumerable? manifestPrompts, IEnumerable discoveredPrompts) + { + var differences = new List(); + if (manifestPrompts == null) return differences; + var manifestByName = manifestPrompts + .Where(p => !string.IsNullOrWhiteSpace(p.Name)) + .ToDictionary(p => p.Name, StringComparer.Ordinal); + + foreach (var prompt in discoveredPrompts) + { + if (string.IsNullOrWhiteSpace(prompt.Name)) continue; + if (!manifestByName.TryGetValue(prompt.Name, out var existing)) continue; + + if (!StringEqualsNormalized(existing.Description, prompt.Description)) + { + differences.Add($"Prompt '{prompt.Name}' description differs (manifest: {FormatValue(existing.Description)}, discovered: {FormatValue(prompt.Description)})."); + } + + var manifestArgs = NormalizeArguments(existing.Arguments); + var discoveredArgs = NormalizeArguments(prompt.Arguments); + if (!manifestArgs.SequenceEqual(discoveredArgs, StringComparer.Ordinal)) + { + differences.Add($"Prompt '{prompt.Name}' arguments differ (manifest: {FormatArguments(manifestArgs)}, discovered: {FormatArguments(discoveredArgs)})."); + } + + var manifestText = NormalizeString(existing.Text); + var discoveredText = NormalizeString(prompt.Text); + if (manifestText == null && discoveredText != null) + { + differences.Add($"Prompt '{prompt.Name}' text differs (manifest length {existing.Text?.Length ?? 0}, discovered length {prompt.Text?.Length ?? 0})."); + } + } + + return differences; + } + + internal static List GetPromptTextWarnings(IEnumerable? manifestPrompts, IEnumerable discoveredPrompts) + { + var warnings = new List(); + var manifestByName = manifestPrompts? + .Where(p => !string.IsNullOrWhiteSpace(p.Name)) + .ToDictionary(p => p.Name, StringComparer.Ordinal); + + foreach (var prompt in discoveredPrompts) + { + if (string.IsNullOrWhiteSpace(prompt.Name)) continue; + var discoveredText = NormalizeString(prompt.Text); + if (discoveredText != null) continue; + + McpbManifestPrompt? existing = null; + if (manifestByName != null) + { + manifestByName.TryGetValue(prompt.Name, out existing); + } + var existingHasText = existing != null && !string.IsNullOrWhiteSpace(existing.Text); + if (existingHasText) + { + warnings.Add($"Prompt '{prompt.Name}' did not return text during discovery; keeping manifest text."); + } + else + { + warnings.Add($"Prompt '{prompt.Name}' did not return text during discovery; consider adding text to manifest manually."); + } + } + + return warnings; + } + + internal static List MergePromptMetadata(IEnumerable? manifestPrompts, IEnumerable discoveredPrompts) + { + var manifestByName = manifestPrompts? + .Where(p => !string.IsNullOrWhiteSpace(p.Name)) + .ToDictionary(p => p.Name, StringComparer.Ordinal); + + return discoveredPrompts + .Where(p => !string.IsNullOrWhiteSpace(p.Name)) + .Select(p => + { + McpbManifestPrompt? existing = null; + if (manifestByName != null) + { + manifestByName.TryGetValue(p.Name, out existing); + } + var mergedText = existing != null && !string.IsNullOrWhiteSpace(existing.Text) + ? existing.Text! + : (!string.IsNullOrWhiteSpace(p.Text) ? p.Text! : string.Empty); + return new McpbManifestPrompt + { + Name = p.Name, + Description = p.Description, + Arguments = p.Arguments != null && p.Arguments.Count > 0 + ? new List(p.Arguments) + : null, + Text = mergedText + }; + }) + .ToList(); + } + + private static bool StringEqualsNormalized(string? a, string? b) + => string.Equals(NormalizeString(a), NormalizeString(b), StringComparison.Ordinal); + + private static string? NormalizeString(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value; + + private static IReadOnlyList NormalizeArguments(IReadOnlyCollection? args) + { + if (args == null || args.Count == 0) return Array.Empty(); + return args.Where(a => !string.IsNullOrWhiteSpace(a)).Select(a => a).ToArray(); + } + + private static string FormatArguments(IReadOnlyList args) + { + if (args.Count == 0) return "[]"; + return "[" + string.Join(", ", args) + "]"; + } + + private static string FormatValue(string? value) + { + var normalized = NormalizeString(value); + return normalized ?? "(none)"; + } +} diff --git a/dotnet/mcpb/Commands/PackCommand.cs b/dotnet/mcpb/Commands/PackCommand.cs new file mode 100644 index 0000000..25aba29 --- /dev/null +++ b/dotnet/mcpb/Commands/PackCommand.cs @@ -0,0 +1,394 @@ +using System.CommandLine; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using Mcpb.Core; +using System.Text.Json; +using Mcpb.Json; +using System.Text.RegularExpressions; + +namespace Mcpb.Commands; + +public static class PackCommand +{ + private static readonly string[] BaseExcludePatterns = new[]{ + ".DS_Store","Thumbs.db",".gitignore",".git",".mcpbignore","*.log",".env",".npm",".npmrc",".yarnrc",".yarn",".eslintrc",".editorconfig",".prettierrc",".prettierignore",".eslintignore",".nycrc",".babelrc",".pnp.*","node_modules/.cache","node_modules/.bin","*.map",".env.local",".env.*.local","npm-debug.log*","yarn-debug.log*","yarn-error.log*","package-lock.json","yarn.lock","*.mcpb","*.d.ts","*.tsbuildinfo","tsconfig.json" + }; + + public static Command Create() + { + var dirArg = new Argument("directory", () => Directory.GetCurrentDirectory(), "Extension directory"); + var outputArg = new Argument("output", () => null, "Output .mcpb path"); + var forceOpt = new Option(name: "--force", description: "Proceed even if discovered tools differ from manifest"); + var updateOpt = new Option(name: "--update", description: "Update manifest tools list to match dynamically discovered tools"); + var noDiscoverOpt = new Option(name: "--no-discover", description: "Skip dynamic tool discovery (for offline / testing)"); + var cmd = new Command("pack", "Pack a directory into an MCPB extension") { dirArg, outputArg, forceOpt, updateOpt, noDiscoverOpt }; + cmd.SetHandler(async (string? directory, string? output, bool force, bool update, bool noDiscover) => + { + var dir = Path.GetFullPath(directory ?? Directory.GetCurrentDirectory()); + if (!Directory.Exists(dir)) { Console.Error.WriteLine($"ERROR: Directory not found: {dir}"); return; } + var manifestPath = Path.Combine(dir, "manifest.json"); + if (!File.Exists(manifestPath)) { Console.Error.WriteLine("ERROR: manifest.json not found"); return; } + if (!ValidateManifestBasic(manifestPath)) { Console.Error.WriteLine("ERROR: Cannot pack invalid manifest"); return; } + + var manifest = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), McpbJsonContext.Default.McpbManifest)!; + + var outPath = output != null + ? Path.GetFullPath(output) + : Path.Combine(Directory.GetCurrentDirectory(), SanitizeFileName(manifest.Name) + ".mcpb"); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + + var ignorePatterns = LoadIgnoreFile(dir); + var files = CollectFiles(dir, ignorePatterns, out var ignoredCount); + + // Manifest already parsed above + + // Validate referenced files (icon, entrypoint, server command if path-like, screenshots) before any discovery + var fileErrors = ManifestCommandHelpers.ValidateReferencedFiles(manifest, dir); + if (fileErrors.Count > 0) + { + foreach (var err in fileErrors) Console.Error.WriteLine($"ERROR: {err}"); + Environment.ExitCode = 1; + return; + } + + // Attempt dynamic discovery unless opted out (tools & prompts) + List? discoveredTools = null; + List? discoveredPrompts = null; + McpbInitializeResult? discoveredInitResponse = null; + McpbToolsListResult? discoveredToolsListResponse = null; + if (!noDiscover) + { + try + { + var result = await ManifestCommandHelpers.DiscoverCapabilitiesAsync( + dir, + manifest, + message => Console.WriteLine(message), + warning => Console.Error.WriteLine($"WARNING: {warning}")); + discoveredTools = result.Tools; + discoveredPrompts = result.Prompts; + discoveredInitResponse = result.InitializeResponse; + discoveredToolsListResponse = result.ToolsListResponse; + } + catch (Exception ex) + { + Console.Error.WriteLine($"WARNING: Tool discovery failed: {ex.Message}"); + } + } + + bool mismatchOccurred = false; + if (discoveredTools != null) + { + var manifestTools = manifest.Tools?.Select(t => t.Name).ToList() ?? new List(); + var discoveredToolNames = discoveredTools.Select(t => t.Name).ToList(); + discoveredToolNames.Sort(StringComparer.Ordinal); + manifestTools.Sort(StringComparer.Ordinal); + bool listMismatch = !manifestTools.SequenceEqual(discoveredToolNames); + if (listMismatch) + { + mismatchOccurred = true; + Console.WriteLine("Tool list mismatch:"); + Console.WriteLine(" Manifest: [" + string.Join(", ", manifestTools) + "]"); + Console.WriteLine(" Discovered: [" + string.Join(", ", discoveredToolNames) + "]"); + } + + var metadataDiffs = ManifestCommandHelpers.GetToolMetadataDifferences(manifest.Tools, discoveredTools); + if (metadataDiffs.Count > 0) + { + mismatchOccurred = true; + Console.WriteLine("Tool metadata mismatch:"); + foreach (var diff in metadataDiffs) + { + Console.WriteLine(" " + diff); + } + } + } + + if (discoveredPrompts != null) + { + var manifestPrompts = manifest.Prompts?.Select(p => p.Name).ToList() ?? new List(); + var discoveredPromptNames = discoveredPrompts.Select(p => p.Name).ToList(); + discoveredPromptNames.Sort(StringComparer.Ordinal); + manifestPrompts.Sort(StringComparer.Ordinal); + bool listMismatch = !manifestPrompts.SequenceEqual(discoveredPromptNames); + if (listMismatch) + { + mismatchOccurred = true; + Console.WriteLine("Prompt list mismatch:"); + Console.WriteLine(" Manifest: [" + string.Join(", ", manifestPrompts) + "]"); + Console.WriteLine(" Discovered: [" + string.Join(", ", discoveredPromptNames) + "]"); + } + + var metadataDiffs = ManifestCommandHelpers.GetPromptMetadataDifferences(manifest.Prompts, discoveredPrompts); + if (metadataDiffs.Count > 0) + { + mismatchOccurred = true; + Console.WriteLine("Prompt metadata mismatch:"); + foreach (var diff in metadataDiffs) + { + Console.WriteLine(" " + diff); + } + } + + var promptWarnings = ManifestCommandHelpers.GetPromptTextWarnings(manifest.Prompts, discoveredPrompts); + foreach (var warning in promptWarnings) + { + Console.Error.WriteLine($"WARNING: {warning}"); + } + } + + // Check static responses in _meta (always update when --update is used) + if (update && (discoveredInitResponse != null || discoveredToolsListResponse != null)) + { + // Get or create _meta["com.microsoft.windows"] + var windowsMeta = GetOrCreateWindowsMeta(manifest); + var staticResponses = windowsMeta.StaticResponses ?? new McpbStaticResponses(); + + // Update static responses in _meta when --update flag is used + if (discoveredInitResponse != null) + { + // Serialize to dictionary to have full control over what's included + var initDict = new Dictionary(); + if (discoveredInitResponse.ProtocolVersion != null) + initDict["protocolVersion"] = discoveredInitResponse.ProtocolVersion; + if (discoveredInitResponse.Capabilities != null) + initDict["capabilities"] = discoveredInitResponse.Capabilities; + if (discoveredInitResponse.ServerInfo != null) + initDict["serverInfo"] = discoveredInitResponse.ServerInfo; + if (!string.IsNullOrWhiteSpace(discoveredInitResponse.Instructions)) + initDict["instructions"] = discoveredInitResponse.Instructions; + + staticResponses.Initialize = initDict; + } + if (discoveredToolsListResponse != null) + { + // Store the entire tools/list response object as-is + staticResponses.ToolsList = discoveredToolsListResponse; + } + windowsMeta.StaticResponses = staticResponses; + SetWindowsMeta(manifest, windowsMeta); + Console.WriteLine("Updated _meta static_responses to match discovered results."); + } + + if (mismatchOccurred) + { + if (update) + { + if (discoveredTools != null) + { + manifest.Tools = discoveredTools + .Select(t => new McpbManifestTool + { + Name = t.Name, + Description = t.Description + }) + .ToList(); + manifest.ToolsGenerated ??= false; + } + if (discoveredPrompts != null) + { + manifest.Prompts = ManifestCommandHelpers.MergePromptMetadata(manifest.Prompts, discoveredPrompts); + manifest.PromptsGenerated ??= false; + } + File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); + Console.WriteLine("Updated manifest.json capabilities to match discovered results."); + } + else if (!force) + { + Console.Error.WriteLine("ERROR: Discovered capabilities differ from manifest. Use --force to ignore or --update to rewrite manifest."); + Environment.ExitCode = 1; + return; + } + else + { + Console.WriteLine("Proceeding due to --force despite capability mismatches."); + } + } + + // Header + Console.WriteLine($"\n📦 {manifest.Name}@{manifest.Version}"); + Console.WriteLine("Archive Contents"); + + long totalUnpacked = 0; + // Build list with sizes + var fileEntries = files.Select(t => new { t.fullPath, t.relative, Size = new FileInfo(t.fullPath).Length }).ToList(); + fileEntries.Sort((a, b) => string.Compare(a.relative, b.relative, StringComparison.Ordinal)); + + // Group deep ( >3 parts ) similar to TS (first 3 segments) + var deepGroups = new Dictionary Files, long Size)>(); + var shallow = new List<(string Rel, long Size)>(); + foreach (var fe in fileEntries) + { + totalUnpacked += fe.Size; + var parts = fe.relative.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 3) + { + var key = string.Join('/', parts.Take(3)); + if (!deepGroups.TryGetValue(key, out var val)) { val = (new List(), 0); } + val.Files.Add(fe.relative); val.Size += fe.Size; deepGroups[key] = val; + } + else shallow.Add((fe.relative, fe.Size)); + } + foreach (var s in shallow) Console.WriteLine($"{FormatSize(s.Size).PadLeft(8)} {s.Rel}"); + foreach (var kv in deepGroups) + { + var (list, size) = kv.Value; + if (list.Count == 1) + Console.WriteLine($"{FormatSize(size).PadLeft(8)} {list[0]}"); + else + Console.WriteLine($"{FormatSize(size).PadLeft(8)} {kv.Key}/ [and {list.Count} more files]"); + } + + using var mem = new MemoryStream(); + using (var zip = new ZipArchive(mem, ZipArchiveMode.Create, true, Encoding.UTF8)) + { + foreach (var (filePath, rel) in files) + { + var entry = zip.CreateEntry(rel, CompressionLevel.SmallestSize); + using var es = entry.Open(); + await using var fs = File.OpenRead(filePath); + await fs.CopyToAsync(es); + } + } + var zipData = mem.ToArray(); + await File.WriteAllBytesAsync(outPath, zipData); + + var sha1 = SHA1.HashData(zipData); + var sanitizedName = SanitizeFileName(manifest.Name); + var archiveName = $"{sanitizedName}-{manifest.Version}.mcpb"; + Console.WriteLine("\nArchive Details"); + Console.WriteLine($"name: {manifest.Name}"); + Console.WriteLine($"version: {manifest.Version}"); + Console.WriteLine($"filename: {archiveName}"); + Console.WriteLine($"package size: {FormatSize(zipData.Length)}"); + Console.WriteLine($"unpacked size: {FormatSize(totalUnpacked)}"); + Console.WriteLine($"shasum: {Convert.ToHexString(sha1).ToLowerInvariant()}"); + Console.WriteLine($"total files: {fileEntries.Count}"); + Console.WriteLine($"ignored (.mcpbignore) files: {ignoredCount}"); + Console.WriteLine($"\nOutput: {outPath}"); + }, dirArg, outputArg, forceOpt, updateOpt, noDiscoverOpt); + return cmd; + } + // Removed reflection-based helpers; using direct SDK types instead. + + private static bool ValidateManifestBasic(string manifestPath) + { + try { var json = File.ReadAllText(manifestPath); return JsonSerializer.Deserialize(json, McpbJsonContext.Default.McpbManifest) != null; } + catch { return false; } + } + + private static List<(string fullPath, string relative)> CollectFiles(string baseDir, List additionalPatterns, out int ignoredCount) + { + ignoredCount = 0; + var results = new List<(string, string)>(); + foreach (var file in Directory.GetFiles(baseDir, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(baseDir, file).Replace('\\', '/'); + if (ShouldExclude(rel, additionalPatterns)) { ignoredCount++; continue; } + results.Add((file, rel)); + } + return results; + } + + private static bool ShouldExclude(string relative, List additional) + { + return Matches(relative, BaseExcludePatterns) || Matches(relative, additional); + } + + private static bool Matches(string relative, IEnumerable patterns) + { + foreach (var pattern in patterns) + { + if (GlobMatch(relative, pattern)) return true; + } + return false; + } + + private static bool GlobMatch(string text, string pattern) + { + // Simple glob: * wildcard, ? single char, supports '**/' for any dir depth + // Convert to regex + var regex = System.Text.RegularExpressions.Regex.Escape(pattern) + .Replace(@"\*\*\/", @"(?:(?:.+/)?)") + .Replace(@"\*", @"[^/]*") + .Replace(@"\?", @"."); + return System.Text.RegularExpressions.Regex.IsMatch(text, "^" + regex + "$"); + } + + private static List LoadIgnoreFile(string baseDir) + { + var path = Path.Combine(baseDir, ".mcpbignore"); + if (!File.Exists(path)) return new List(); + return File.ReadAllLines(path) + .Select(l => l.Trim()) + .Where(l => l.Length > 0 && !l.StartsWith("#")) + .ToList(); + } + + private static string FormatSize(long bytes) + { + if (bytes < 1024) return $"{bytes}B"; if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1}kB"; return $"{bytes / (1024.0 * 1024):F1}MB"; + } + + private static string SanitizeFileName(string name) + { + var sanitized = RegexReplace(name, "\\s+", "-"); + sanitized = RegexReplace(sanitized, "[^A-Za-z0-9-_.]", ""); + sanitized = RegexReplace(sanitized, "-+", "-"); + sanitized = sanitized.Trim('-'); + if (sanitized.Length > 100) sanitized = sanitized.Substring(0, 100); + return sanitized; + } + private static string RegexReplace(string input, string pattern, string replacement) => System.Text.RegularExpressions.Regex.Replace(input, pattern, replacement); + + private static McpbWindowsMeta GetOrCreateWindowsMeta(McpbManifest manifest) + { + manifest.Meta ??= new Dictionary>(); + + if (!manifest.Meta.TryGetValue("com.microsoft.windows", out var windowsMetaDict)) + { + return new McpbWindowsMeta(); + } + + // Try to deserialize the dictionary to McpbWindowsMeta + try + { + var json = JsonSerializer.Serialize(windowsMetaDict); + return JsonSerializer.Deserialize(json) ?? new McpbWindowsMeta(); + } + catch + { + return new McpbWindowsMeta(); + } + } + + private static void SetWindowsMeta(McpbManifest manifest, McpbWindowsMeta windowsMeta) + { + manifest.Meta ??= new Dictionary>(); + + // Serialize to dictionary + var json = JsonSerializer.Serialize(windowsMeta); + var dict = JsonSerializer.Deserialize>(json) ?? new Dictionary(); + + manifest.Meta["com.microsoft.windows"] = dict; + } + + private static bool AreStaticResponsesEqual(object? a, object? b) + { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + + try + { + var jsonA = JsonSerializer.Serialize(a); + var jsonB = JsonSerializer.Serialize(b); + return jsonA == jsonB; + } + catch + { + return false; + } + } + +} diff --git a/dotnet/mcpb/Commands/SignCommand.cs b/dotnet/mcpb/Commands/SignCommand.cs new file mode 100644 index 0000000..a23c1ed --- /dev/null +++ b/dotnet/mcpb/Commands/SignCommand.cs @@ -0,0 +1,229 @@ +using System.CommandLine; +using System.Security.Cryptography; +using System.Security.Cryptography.Pkcs; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Mcpb.Commands; + +public static class SignCommand +{ + public static Command Create() + { + var fileArg = new Argument("mcpb-file", description: "Path to .mcpb file"); + var certOpt = new Option(new[]{"--cert","-c"}, () => "cert.pem", "Path to certificate (PEM)"); + var keyOpt = new Option(new[]{"--key","-k"}, () => "key.pem", "Path to private key (PEM)"); + var selfSignedOpt = new Option("--self-signed", description: "Create self-signed certificate if missing"); + var cmd = new Command("sign", "Sign an MCPB extension file") { fileArg, certOpt, keyOpt, selfSignedOpt }; + cmd.SetHandler((string file, string cert, string key, bool selfSigned) => + { + var path = Path.GetFullPath(file); + if (!File.Exists(path)) { Console.Error.WriteLine($"ERROR: MCPB file not found: {file}"); return; } + if (selfSigned && (!File.Exists(cert) || !File.Exists(key))) + { + Console.WriteLine("Creating self-signed certificate..."); + CreateSelfSigned(cert, key); + } + if (!File.Exists(cert) || !File.Exists(key)) + { + Console.Error.WriteLine("ERROR: Certificate or key file not found"); + return; + } + try + { + Console.WriteLine($"Signing {Path.GetFileName(path)}..."); + var original = File.ReadAllBytes(path); + var (content, _) = SignatureHelpers.ExtractSignatureBlock(original); + var pkcs7 = SignatureHelpers.CreateDetachedPkcs7(content, cert, key); + var signatureBlock = SignatureHelpers.CreateSignatureBlock(pkcs7); + File.WriteAllBytes(path, content.Concat(signatureBlock).ToArray()); + Console.WriteLine($"Successfully signed {Path.GetFileName(path)}"); + // Basic signer info (chain trust not implemented yet) + var (orig2, sig2) = SignatureHelpers.ExtractSignatureBlock(File.ReadAllBytes(path)); + if (sig2 != null && SignatureHelpers.Verify(orig2, sig2, out var signerCert) && signerCert != null) + { + Console.WriteLine($"Signed by: {signerCert.Subject}"); + Console.WriteLine($"Issuer: {signerCert.Issuer}"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"ERROR: Signing failed: {ex.Message}"); + } + }, fileArg, certOpt, keyOpt, selfSignedOpt); + return cmd; + } + + private static void CreateSelfSigned(string certPath, string keyPath) + { + using var rsa = RSA.Create(4096); + var req = new CertificateRequest("CN=MCPB Self-Signed Certificate", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false,false,0,false)); + req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10)); + var certPem = cert.ExportCertificatePem(); + var keyPem = rsa.ExportPkcs8PrivateKeyPem(); + File.WriteAllText(certPath, certPem + Environment.NewLine); + File.WriteAllText(keyPath, keyPem + Environment.NewLine); + } +} + +internal static class SignatureHelpers +{ + private const string SignatureHeader = "MCPB_SIG_V1"; + private const string SignatureFooter = "MCPB_SIG_END"; + + public static (byte[] Original, byte[]? Signature) ExtractSignatureBlock(byte[] fileContent) + { + var footerBytes = Encoding.UTF8.GetBytes(SignatureFooter); + var headerBytes = Encoding.UTF8.GetBytes(SignatureHeader); + int footerIndex = LastIndexOf(fileContent, footerBytes); + if (footerIndex == -1) return (fileContent, null); + int headerIndex = -1; + for (int i = footerIndex - 1; i >= 0; i--) + { + if (StartsWithAt(fileContent, headerBytes, i)) { headerIndex = i; break; } + } + if (headerIndex == -1) return (fileContent, null); + int lenOffset = headerIndex + headerBytes.Length; + if (lenOffset + 4 > fileContent.Length) return (fileContent, null); + int sigLen = BitConverter.ToInt32(fileContent, lenOffset); + var sigStart = lenOffset + 4; + if (sigStart + sigLen > fileContent.Length) return (fileContent, null); + var sig = new byte[sigLen]; + Buffer.BlockCopy(fileContent, sigStart, sig, 0, sigLen); + return (fileContent.Take(headerIndex).ToArray(), sig); + } + + public static byte[] CreateSignatureBlock(byte[] pkcs7) + { + var header = Encoding.UTF8.GetBytes(SignatureHeader); + var footer = Encoding.UTF8.GetBytes(SignatureFooter); + var len = BitConverter.GetBytes(pkcs7.Length); + using var ms = new MemoryStream(); + ms.Write(header); + ms.Write(len); + ms.Write(pkcs7); + ms.Write(footer); + return ms.ToArray(); + } + + public static byte[] CreateDetachedPkcs7(byte[] content, string certPemPath, string keyPemPath) + { + // Manual PEM parsing for reliability across environments + try + { + string certText = File.ReadAllText(certPemPath); + string keyText = File.ReadAllText(keyPemPath); + + static byte[] ExtractPem(string text, string label) + { + var begin = $"-----BEGIN {label}-----"; + var end = $"-----END {label}-----"; + int start = text.IndexOf(begin, StringComparison.Ordinal); + if (start < 0) throw new CryptographicException($"Missing PEM begin marker for {label}."); + start += begin.Length; + int endIdx = text.IndexOf(end, start, StringComparison.Ordinal); + if (endIdx < 0) throw new CryptographicException($"Missing PEM end marker for {label}."); + var base64 = text.Substring(start, endIdx - start) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim(); + return Convert.FromBase64String(base64); + } + + var certDer = ExtractPem(certText, "CERTIFICATE"); + using var rsa = RSA.Create(); + try + { + // Support PKCS8 or traditional RSA PRIVATE KEY + if (keyText.Contains("PRIVATE KEY")) + { + if (keyText.Contains("BEGIN PRIVATE KEY")) + { + var pkcs8 = ExtractPem(keyText, "PRIVATE KEY"); + rsa.ImportPkcs8PrivateKey(pkcs8, out _); + } + else if (keyText.Contains("BEGIN RSA PRIVATE KEY")) + { + var pkcs1 = ExtractPem(keyText, "RSA PRIVATE KEY"); + rsa.ImportRSAPrivateKey(pkcs1, out _); + } + } + else + { + throw new CryptographicException("Unsupported key PEM format."); + } + } + catch (Exception ex) + { + throw new CryptographicException("Failed to parse private key PEM: " + ex.Message, ex); + } + + var baseCert = new X509Certificate2(certDer); + var cert = baseCert.CopyWithPrivateKey(rsa); + var contentInfo = new ContentInfo(new Oid("1.2.840.113549.1.7.1"), content); // data OID + var cms = new SignedCms(contentInfo, detached: true); // Back to detached + var signer = new CmsSigner(SubjectIdentifierType.SubjectKeyIdentifier, cert) + { + IncludeOption = X509IncludeOption.EndCertOnly + }; + cms.ComputeSignature(signer); + return cms.Encode(); + } + catch + { + // Fallback to built-in API if manual path failed (may throw original later) + var cert = X509Certificate2.CreateFromPemFile(certPemPath, keyPemPath); + if (!cert.HasPrivateKey) + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(keyPemPath)); + cert = cert.CopyWithPrivateKey(rsa); + } + var contentInfo = new ContentInfo(new Oid("1.2.840.113549.1.7.1"), content); // data OID + var cms = new SignedCms(contentInfo, detached: true); // Back to detached + var signer = new CmsSigner(SubjectIdentifierType.SubjectKeyIdentifier, cert) + { + IncludeOption = X509IncludeOption.EndCertOnly + }; + cms.ComputeSignature(signer); + return cms.Encode(); + } + } + + public static bool Verify(byte[] content, byte[] signature, out X509Certificate2? signerCert) + { + signerCert = null; + try + { + var cms = new SignedCms(new ContentInfo(content), detached: true); + cms.Decode(signature); + cms.CheckSignature(verifySignatureOnly: true); + signerCert = cms.Certificates.Count > 0 ? cms.Certificates[0] : null; + return true; + } + catch { return false; } + } + + public static byte[] RemoveSignature(byte[] fileContent) + { + var (original, _) = ExtractSignatureBlock(fileContent); + return original; + } + + private static int LastIndexOf(byte[] source, byte[] pattern) + { + for (int i = source.Length - pattern.Length; i >= 0; i--) + { + if (StartsWithAt(source, pattern, i)) return i; + } + return -1; + } + private static bool StartsWithAt(byte[] source, byte[] pattern, int index) + { + if (index + pattern.Length > source.Length) return false; + for (int i = 0; i < pattern.Length; i++) if (source[index + i] != pattern[i]) return false; + return true; + } +} \ No newline at end of file diff --git a/dotnet/mcpb/Commands/UnpackCommand.cs b/dotnet/mcpb/Commands/UnpackCommand.cs new file mode 100644 index 0000000..a2a70aa --- /dev/null +++ b/dotnet/mcpb/Commands/UnpackCommand.cs @@ -0,0 +1,41 @@ +using System.CommandLine; +using System.IO.Compression; + +namespace Mcpb.Commands; + +public static class UnpackCommand +{ + public static Command Create() + { + var fileArg = new Argument("mcpb-file", description: "Path to .mcpb file"); + var outputArg = new Argument("output", () => null, description: "Output directory"); + var cmd = new Command("unpack", "Unpack an MCPB extension file") { fileArg, outputArg }; + cmd.SetHandler((string file, string? output) => + { + var path = Path.GetFullPath(file); + if (!File.Exists(path)) { Console.Error.WriteLine($"ERROR: MCPB file not found: {path}"); return; } + var outDir = output != null ? Path.GetFullPath(output) : Directory.GetCurrentDirectory(); + Directory.CreateDirectory(outDir); + try + { + using var fs = File.OpenRead(path); + using var zip = new ZipArchive(fs, ZipArchiveMode.Read); + foreach (var entry in zip.Entries) + { + var targetPath = Path.Combine(outDir, entry.FullName); + if (targetPath.Contains("..")) throw new InvalidOperationException("Path traversal detected"); + var dir = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + if (entry.FullName.EndsWith("/")) continue; // directory + entry.ExtractToFile(targetPath, overwrite: true); + } + Console.WriteLine($"Extension unpacked successfully to {outDir}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"ERROR: Failed to unpack extension: {ex.Message}"); + } + }, fileArg, outputArg); + return cmd; + } +} \ No newline at end of file diff --git a/dotnet/mcpb/Commands/UnsignCommand.cs b/dotnet/mcpb/Commands/UnsignCommand.cs new file mode 100644 index 0000000..5641fbc --- /dev/null +++ b/dotnet/mcpb/Commands/UnsignCommand.cs @@ -0,0 +1,37 @@ +using System.CommandLine; + +namespace Mcpb.Commands; + +public static class UnsignCommand +{ + public static Command Create() + { + var fileArg = new Argument("mcpb-file", "Path to .mcpb file"); + var cmd = new Command("unsign", "Remove signature from a MCPB file") { fileArg }; + cmd.SetHandler((string file)=> + { + var path = Path.GetFullPath(file); + if (!File.Exists(path)) { Console.Error.WriteLine($"ERROR: MCPB file not found: {file}"); return; } + try + { + var bytes = File.ReadAllBytes(path); + var (original, sig) = SignatureHelpers.ExtractSignatureBlock(bytes); + Console.WriteLine($"Removing signature from {Path.GetFileName(path)}..."); + if (sig == null) + { + Console.WriteLine("WARNING: File not signed"); + } + else + { + File.WriteAllBytes(path, original); + Console.WriteLine("Signature removed"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"ERROR: Failed to remove signature: {ex.Message}"); + } + }, fileArg); + return cmd; + } +} diff --git a/dotnet/mcpb/Commands/ValidateCommand.cs b/dotnet/mcpb/Commands/ValidateCommand.cs new file mode 100644 index 0000000..5814ef3 --- /dev/null +++ b/dotnet/mcpb/Commands/ValidateCommand.cs @@ -0,0 +1,275 @@ +using System.CommandLine; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using Mcpb.Core; +using Mcpb.Json; + +namespace Mcpb.Commands; + +public static class ValidateCommand +{ + public static Command Create() + { + var manifestArg = new Argument("manifest", description: "Path to manifest.json or its directory"); + manifestArg.Arity = ArgumentArity.ZeroOrOne; + var dirnameOpt = new Option("--dirname", description: "Directory containing referenced files and server entry point"); + var updateOpt = new Option("--update", description: "Update manifest tools/prompts to match discovery results"); + var cmd = new Command("validate", "Validate an MCPB manifest file") { manifestArg, dirnameOpt, updateOpt }; + cmd.SetHandler(async (string? path, string? dirname, bool update) => + { + if (update && string.IsNullOrWhiteSpace(dirname)) + { + Console.Error.WriteLine("ERROR: --update requires --dirname to locate manifest assets."); + Environment.ExitCode = 1; + return; + } + if (string.IsNullOrWhiteSpace(path)) + { + if (!string.IsNullOrWhiteSpace(dirname)) + { + path = Path.Combine(dirname, "manifest.json"); + } + else + { + Console.Error.WriteLine("ERROR: Manifest path or --dirname must be specified."); + Environment.ExitCode = 1; + return; + } + } + var manifestPath = path!; + if (Directory.Exists(manifestPath)) + { + manifestPath = Path.Combine(manifestPath, "manifest.json"); + } + if (!File.Exists(manifestPath)) + { + Console.Error.WriteLine($"ERROR: File not found: {manifestPath}"); + Environment.ExitCode = 1; + return; + } + string json; + try + { + json = File.ReadAllText(manifestPath); + if (Environment.GetEnvironmentVariable("MCPB_DEBUG_VALIDATE") == "1") + { + Console.WriteLine($"DEBUG: Read manifest {manifestPath} length={json.Length}"); + } + + static void PrintWarnings(IEnumerable warnings, bool toError) + { + foreach (var warning in warnings) + { + var msg = string.IsNullOrEmpty(warning.Path) + ? warning.Message + : $"{warning.Path}: {warning.Message}"; + if (toError) Console.Error.WriteLine($"Warning: {msg}"); + else Console.WriteLine($"Warning: {msg}"); + } + } + + var issues = ManifestValidator.ValidateJson(json); + var errors = issues.Where(i => i.Severity == ValidationSeverity.Error).ToList(); + var warnings = issues.Where(i => i.Severity == ValidationSeverity.Warning).ToList(); + if (errors.Count > 0) + { + Console.Error.WriteLine("ERROR: Manifest validation failed:\n"); + foreach (var issue in errors) + { + var pfx = string.IsNullOrEmpty(issue.Path) ? "" : issue.Path + ": "; + Console.Error.WriteLine($" - {pfx}{issue.Message}"); + } + PrintWarnings(warnings, toError: true); + Environment.ExitCode = 1; + return; + } + + var manifest = JsonSerializer.Deserialize(json, McpbJsonContext.Default.McpbManifest)!; + var currentWarnings = new List(warnings); + var additionalErrors = new List(); + + if (!string.IsNullOrWhiteSpace(dirname)) + { + var baseDir = Path.GetFullPath(dirname); + if (!Directory.Exists(baseDir)) + { + Console.Error.WriteLine($"ERROR: Directory not found: {baseDir}"); + PrintWarnings(currentWarnings, toError: true); + Environment.ExitCode = 1; + return; + } + + var fileErrors = ManifestCommandHelpers.ValidateReferencedFiles(manifest, baseDir); + foreach (var err in fileErrors) + { + additionalErrors.Add($"ERROR: {err}"); + } + + var discovery = await ManifestCommandHelpers.DiscoverCapabilitiesAsync( + baseDir, + manifest, + message => Console.WriteLine(message), + warning => Console.Error.WriteLine($"WARNING: {warning}")); + + var discoveredTools = discovery.Tools; + var discoveredPrompts = discovery.Prompts; + + var manifestTools = manifest.Tools?.Select(t => t.Name).ToList() ?? new List(); + var manifestPrompts = manifest.Prompts?.Select(p => p.Name).ToList() ?? new List(); + + var sortedDiscoveredTools = discoveredTools.Select(t => t.Name).ToList(); + var sortedDiscoveredPrompts = discoveredPrompts.Select(p => p.Name).ToList(); + manifestTools.Sort(StringComparer.Ordinal); + manifestPrompts.Sort(StringComparer.Ordinal); + sortedDiscoveredTools.Sort(StringComparer.Ordinal); + sortedDiscoveredPrompts.Sort(StringComparer.Ordinal); + + bool toolMismatch = !manifestTools.SequenceEqual(sortedDiscoveredTools); + bool promptMismatch = !manifestPrompts.SequenceEqual(sortedDiscoveredPrompts); + + var toolMetadataDiffs = ManifestCommandHelpers.GetToolMetadataDifferences(manifest.Tools, discoveredTools); + var promptMetadataDiffs = ManifestCommandHelpers.GetPromptMetadataDifferences(manifest.Prompts, discoveredPrompts); + bool toolMetadataMismatch = toolMetadataDiffs.Count > 0; + bool promptMetadataMismatch = promptMetadataDiffs.Count > 0; + + bool mismatchOccurred = toolMismatch || promptMismatch || toolMetadataMismatch || promptMetadataMismatch; + + if (toolMismatch) + { + Console.WriteLine("Tool list mismatch:"); + Console.WriteLine(" Manifest: [" + string.Join(", ", manifestTools) + "]"); + Console.WriteLine(" Discovered: [" + string.Join(", ", sortedDiscoveredTools) + "]"); + } + + if (toolMetadataMismatch) + { + Console.WriteLine("Tool metadata mismatch:"); + foreach (var diff in toolMetadataDiffs) + { + Console.WriteLine(" " + diff); + } + } + + if (promptMismatch) + { + Console.WriteLine("Prompt list mismatch:"); + Console.WriteLine(" Manifest: [" + string.Join(", ", manifestPrompts) + "]"); + Console.WriteLine(" Discovered: [" + string.Join(", ", sortedDiscoveredPrompts) + "]"); + } + + if (promptMetadataMismatch) + { + Console.WriteLine("Prompt metadata mismatch:"); + foreach (var diff in promptMetadataDiffs) + { + Console.WriteLine(" " + diff); + } + } + + var promptWarnings = ManifestCommandHelpers.GetPromptTextWarnings(manifest.Prompts, discoveredPrompts); + foreach (var warning in promptWarnings) + { + Console.Error.WriteLine($"WARNING: {warning}"); + } + + if (mismatchOccurred) + { + if (update) + { + if (toolMismatch || toolMetadataMismatch) + { + manifest.Tools = discoveredTools + .Select(t => new McpbManifestTool + { + Name = t.Name, + Description = t.Description + }) + .ToList(); + manifest.ToolsGenerated ??= false; + } + if (promptMismatch || promptMetadataMismatch) + { + manifest.Prompts = ManifestCommandHelpers.MergePromptMetadata(manifest.Prompts, discoveredPrompts); + manifest.PromptsGenerated ??= false; + } + + var updatedJson = JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions); + var updatedIssues = ManifestValidator.ValidateJson(updatedJson); + var updatedErrors = updatedIssues.Where(i => i.Severity == ValidationSeverity.Error).ToList(); + var updatedWarnings = updatedIssues.Where(i => i.Severity == ValidationSeverity.Warning).ToList(); + var updatedManifest = JsonSerializer.Deserialize(updatedJson, McpbJsonContext.Default.McpbManifest)!; + + File.WriteAllText(manifestPath, updatedJson); + + if (updatedErrors.Count > 0) + { + Console.Error.WriteLine("ERROR: Updated manifest validation failed (updated file written):\n"); + foreach (var issue in updatedErrors) + { + var pfx = string.IsNullOrEmpty(issue.Path) ? string.Empty : issue.Path + ": "; + Console.Error.WriteLine($" - {pfx}{issue.Message}"); + } + PrintWarnings(updatedWarnings, toError: true); + Environment.ExitCode = 1; + return; + } + + var updatedManifestTools = updatedManifest.Tools?.Select(t => t.Name).ToList() ?? new List(); + var updatedManifestPrompts = updatedManifest.Prompts?.Select(p => p.Name).ToList() ?? new List(); + updatedManifestTools.Sort(StringComparer.Ordinal); + updatedManifestPrompts.Sort(StringComparer.Ordinal); + if (!updatedManifestTools.SequenceEqual(sortedDiscoveredTools) || !updatedManifestPrompts.SequenceEqual(sortedDiscoveredPrompts)) + { + Console.Error.WriteLine("ERROR: Updated manifest still differs from discovered capability names (updated file written)."); + PrintWarnings(updatedWarnings, toError: true); + Environment.ExitCode = 1; + return; + } + + var remainingToolDiffs = ManifestCommandHelpers.GetToolMetadataDifferences(updatedManifest.Tools, discoveredTools); + var remainingPromptDiffs = ManifestCommandHelpers.GetPromptMetadataDifferences(updatedManifest.Prompts, discoveredPrompts); + if (remainingToolDiffs.Count > 0 || remainingPromptDiffs.Count > 0) + { + Console.Error.WriteLine("ERROR: Updated manifest metadata still differs from discovered results (updated file written)."); + PrintWarnings(updatedWarnings, toError: true); + Environment.ExitCode = 1; + return; + } + + Console.WriteLine("Updated manifest.json capabilities to match discovered results."); + manifest = updatedManifest; + currentWarnings = new List(updatedWarnings); + } + else + { + additionalErrors.Add("ERROR: Discovered capabilities differ from manifest (names or metadata). Use --update to rewrite manifest."); + } + } + } + + if (additionalErrors.Count > 0) + { + foreach (var err in additionalErrors) + { + Console.Error.WriteLine(err); + } + PrintWarnings(currentWarnings, toError: true); + Environment.ExitCode = 1; + return; + } + + Console.WriteLine("Manifest is valid!"); + PrintWarnings(currentWarnings, toError: false); + Console.Out.Flush(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"ERROR: {ex.Message}"); + Environment.ExitCode = 1; + } + }, manifestArg, dirnameOpt, updateOpt); + return cmd; + } +} \ No newline at end of file diff --git a/dotnet/mcpb/Commands/VerifyCommand.cs b/dotnet/mcpb/Commands/VerifyCommand.cs new file mode 100644 index 0000000..b406759 --- /dev/null +++ b/dotnet/mcpb/Commands/VerifyCommand.cs @@ -0,0 +1,46 @@ +using System.CommandLine; +using System.Security.Cryptography.X509Certificates; + +namespace Mcpb.Commands; + +public static class VerifyCommand +{ + public static Command Create() + { + var fileArg = new Argument("mcpb-file", "Path to .mcpb file"); + var cmd = new Command("verify", "Verify signature of an MCPB file") { fileArg }; + cmd.SetHandler((string file) => + { + var path = Path.GetFullPath(file); + if (!File.Exists(path)) { Console.Error.WriteLine($"ERROR: MCPB file not found: {file}"); return; } + try + { + var content = File.ReadAllBytes(path); + var (original, sig) = SignatureHelpers.ExtractSignatureBlock(content); + Console.WriteLine($"Verifying {Path.GetFileName(path)}..."); + if (sig == null) + { + Console.Error.WriteLine("ERROR: Extension is not signed"); + return; + } + if (SignatureHelpers.Verify(original, sig, out var cert) && cert != null) + { + Console.WriteLine("Signature is valid"); + Console.WriteLine($"Signed by: {cert.Subject}"); + Console.WriteLine($"Issuer: {cert.Issuer}"); + Console.WriteLine($"Valid from: {cert.NotBefore:MM/dd/yyyy} to {cert.NotAfter:MM/dd/yyyy}"); + Console.WriteLine($"Fingerprint: {cert.Thumbprint}"); + } + else + { + Console.Error.WriteLine("ERROR: Invalid signature"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"ERROR: Verification failed: {ex.Message}"); + } + }, fileArg); + return cmd; + } +} diff --git a/dotnet/mcpb/Core/ManifestModels.cs b/dotnet/mcpb/Core/ManifestModels.cs new file mode 100644 index 0000000..e4e8aa9 --- /dev/null +++ b/dotnet/mcpb/Core/ManifestModels.cs @@ -0,0 +1,183 @@ +using System.Text.Json.Serialization; + +namespace Mcpb.Core; + +public class McpServerConfig +{ + [JsonPropertyName("command")] public string Command { get; set; } = "node"; + [JsonPropertyName("args")] public List? Args { get; set; } = new(); + [JsonPropertyName("env")] public Dictionary? Env { get; set; } +} + +public class McpServerConfigWithOverrides : McpServerConfig +{ + [JsonPropertyName("platform_overrides")] public Dictionary? PlatformOverrides { get; set; } +} + +public class McpbManifestServer +{ + [JsonPropertyName("type")] public string Type { get; set; } = "node"; // python|node|binary + [JsonPropertyName("entry_point")] public string EntryPoint { get; set; } = "server/index.js"; + [JsonPropertyName("mcp_config")] public McpServerConfigWithOverrides McpConfig { get; set; } = new(); +} + +public class McpbManifestAuthor +{ + [JsonPropertyName("name")] public string Name { get; set; } = "Unknown Author"; + [JsonPropertyName("email")] public string? Email { get; set; } + [JsonPropertyName("url")] public string? Url { get; set; } +} + +public class McpbManifestRepository +{ + [JsonPropertyName("type")] public string Type { get; set; } = "git"; + [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; +} + +public class McpbManifestCompatibilityRuntimes +{ + [JsonPropertyName("python")] public string? Python { get; set; } + [JsonPropertyName("node")] public string? Node { get; set; } +} + +public class McpbManifestCompatibility +{ + [JsonPropertyName("claude_desktop")] public string? ClaudeDesktop { get; set; } + [JsonPropertyName("platforms")] public List? Platforms { get; set; } + [JsonPropertyName("runtimes")] public McpbManifestCompatibilityRuntimes? Runtimes { get; set; } +} + +public class McpbManifestTool +{ + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("description")] public string? Description { get; set; } +} + +public class McpbManifestPrompt +{ + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("description")] public string? Description { get; set; } + [JsonPropertyName("arguments")] public List? Arguments { get; set; } + [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; +} + +public class McpbUserConfigOption +{ + [JsonPropertyName("type")] public string Type { get; set; } = "string"; // string|number|boolean|directory|file + [JsonPropertyName("title")] public string Title { get; set; } = string.Empty; + [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; + [JsonPropertyName("required")] public bool? Required { get; set; } + [JsonPropertyName("default")] public object? Default { get; set; } + [JsonPropertyName("multiple")] public bool? Multiple { get; set; } + [JsonPropertyName("sensitive")] public bool? Sensitive { get; set; } + [JsonPropertyName("min")] public double? Min { get; set; } + [JsonPropertyName("max")] public double? Max { get; set; } +} + +public class McpbInitializeResult +{ + [JsonPropertyName("protocolVersion")] public string? ProtocolVersion { get; set; } + [JsonPropertyName("capabilities")] public object? Capabilities { get; set; } + [JsonPropertyName("serverInfo")] public object? ServerInfo { get; set; } + [JsonPropertyName("instructions")] public string? Instructions { get; set; } +} + +public class McpbToolsListResult +{ + [JsonPropertyName("tools")] public List? Tools { get; set; } +} + +public class McpbStaticResponses +{ + [JsonPropertyName("initialize")] public object? Initialize { get; set; } + [JsonPropertyName("tools/list")] public McpbToolsListResult? ToolsList { get; set; } +} + +public class McpbWindowsMeta +{ + [JsonPropertyName("static_responses")] public McpbStaticResponses? StaticResponses { get; set; } +} + +public class McpbManifest +{ + [JsonPropertyName("$schema")] public string? Schema { get; set; } + // Deprecated: prefer manifest_version + [JsonPropertyName("dxt_version")] public string? DxtVersion { get; set; } + [JsonPropertyName("manifest_version")] public string ManifestVersion { get; set; } = "0.2"; + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("display_name")] public string? DisplayName { get; set; } + [JsonPropertyName("version")] public string Version { get; set; } = "1.0.0"; + [JsonPropertyName("description")] public string Description { get; set; } = "A MCPB bundle"; + [JsonPropertyName("long_description")] public string? LongDescription { get; set; } + [JsonPropertyName("author")] public McpbManifestAuthor Author { get; set; } = new(); + [JsonPropertyName("repository")] public McpbManifestRepository? Repository { get; set; } + [JsonPropertyName("homepage")] public string? Homepage { get; set; } + [JsonPropertyName("documentation")] public string? Documentation { get; set; } + [JsonPropertyName("support")] public string? Support { get; set; } + [JsonPropertyName("icon")] public string? Icon { get; set; } + [JsonPropertyName("screenshots")] public List? Screenshots { 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; } + [JsonPropertyName("prompts")] public List? Prompts { get; set; } + [JsonPropertyName("prompts_generated")] public bool? PromptsGenerated { get; set; } + [JsonPropertyName("keywords")] public List? Keywords { get; set; } + [JsonPropertyName("license")] public string? License { get; set; } = "MIT"; + [JsonPropertyName("privacy_policies")] public List? PrivacyPolicies { get; set; } + [JsonPropertyName("compatibility")] public McpbManifestCompatibility? Compatibility { get; set; } + [JsonPropertyName("user_config")] public Dictionary? UserConfig { get; set; } + [JsonPropertyName("_meta")] public Dictionary>? Meta { get; set; } +} + +public static class ManifestDefaults +{ + public static McpbManifest Create(string dir) => Create(dir, DetectServerType(dir), null); + + public static McpbManifest Create(string dir, string serverType, string? entryPoint) + { + var name = new DirectoryInfo(dir).Name; + if (string.IsNullOrWhiteSpace(serverType)) serverType = "binary"; + bool isWindows = OperatingSystem.IsWindows(); + entryPoint ??= serverType switch + { + "node" => "server/index.js", + "python" => "server/main.py", + _ => isWindows ? $"server/{name}.exe" : $"server/{name}" // binary + }; + return new McpbManifest + { + Name = name, + Author = new McpbManifestAuthor { Name = "Unknown Author" }, + Server = new McpbManifestServer + { + Type = serverType, + EntryPoint = entryPoint, + McpConfig = new McpServerConfigWithOverrides + { + Command = serverType switch + { + "node" => "node", + "python" => "python", + _ => isWindows ? "${__dirname}/" + entryPoint : "${__dirname}/" + entryPoint + }, + Args = serverType switch + { + "node" => new List { "${__dirname}/" + entryPoint }, + "python" => new List { "${__dirname}/" + entryPoint }, + _ => new List() + }, + Env = new Dictionary() + } + }, + Keywords = new List() + }; + } + + private static string DetectServerType(string dir) + { + // Heuristics: prefer node if package.json + server/index.js, python if server/main.py, else binary + bool hasNode = File.Exists(Path.Combine(dir, "package.json")) && File.Exists(Path.Combine(dir, "server", "index.js")); + bool hasPython = File.Exists(Path.Combine(dir, "server", "main.py")); + return hasNode ? "node" : (hasPython ? "python" : "binary"); + } +} diff --git a/dotnet/mcpb/Core/ManifestValidator.cs b/dotnet/mcpb/Core/ManifestValidator.cs new file mode 100644 index 0000000..148fbb6 --- /dev/null +++ b/dotnet/mcpb/Core/ManifestValidator.cs @@ -0,0 +1,166 @@ +using Mcpb.Core; +using System.Text.RegularExpressions; + +namespace Mcpb.Core; + +public enum ValidationSeverity +{ + Error, + Warning +} + +public record ValidationIssue(string Path, string Message, ValidationSeverity Severity = ValidationSeverity.Error); + +public static class ManifestValidator +{ + public static List Validate(McpbManifest m, HashSet? rootProps = null) + { + var issues = new List(); + bool Has(string? s) => !string.IsNullOrWhiteSpace(s); + + if (Environment.GetEnvironmentVariable("MCPB_DEBUG_VALIDATE") == "1") + { + Console.WriteLine("DBG_VALIDATE:DescriptionPropertyValue=" + (m.Description == null ? "" : m.Description)); + Console.WriteLine("DBG_VALIDATE:RootProps=" + (rootProps == null ? "" : string.Join(",", rootProps))); + } + + var dxtPropInfo = m.GetType().GetProperty("DxtVersion"); + var dxtValue = dxtPropInfo != null ? (string?)dxtPropInfo.GetValue(m) : null; + + bool manifestPropPresent = rootProps?.Contains("manifest_version") == true; + bool dxtPropPresent = rootProps?.Contains("dxt_version") == true; + bool manifestValPresent = Has(m.ManifestVersion); + bool dxtValPresent = Has(dxtValue); + + // Canonical logic: manifest_version supersedes dxt_version. Only warn if ONLY dxt_version present. + if (rootProps != null) + { + bool effectiveManifest = manifestPropPresent && manifestValPresent; + bool effectiveDxt = dxtPropPresent && dxtValPresent; + if (!effectiveManifest && !effectiveDxt) + issues.Add(new("manifest_version", "either manifest_version or deprecated dxt_version is required")); + else if (!effectiveManifest && effectiveDxt) + issues.Add(new("dxt_version", "dxt_version is deprecated; use manifest_version", ValidationSeverity.Warning)); + else if (effectiveManifest && effectiveDxt && !string.Equals(m.ManifestVersion, dxtValue, StringComparison.Ordinal)) + issues.Add(new("dxt_version", "dxt_version value differs from manifest_version (manifest_version is canonical)")); + } + else + { + bool effectiveManifest = manifestValPresent; + bool effectiveDxt = dxtValPresent && !manifestValPresent; + if (!effectiveManifest && !dxtValPresent) + issues.Add(new("manifest_version", "either manifest_version or deprecated dxt_version is required")); + else if (effectiveDxt) + issues.Add(new("dxt_version", "dxt_version is deprecated; use manifest_version", ValidationSeverity.Warning)); + } + + // (Removed experimental dynamic required detection; explicit checks below suffice) + + bool RootMissing(string p) => rootProps != null && !rootProps.Contains(p); + + if (RootMissing("name") || !Has(m.Name)) issues.Add(new("name", "name is required")); + if (RootMissing("version") || !Has(m.Version)) issues.Add(new("version", "version is required")); + if (RootMissing("description") || !Has(m.Description)) issues.Add(new("description", "description is required")); + if (m.Author == null) issues.Add(new("author", "author object is required")); + else if (!Has(m.Author.Name)) issues.Add(new("author.name", "author.name is required")); + if (RootMissing("server") || m.Server == null) issues.Add(new("server", "server is required")); + else + { + if (string.IsNullOrWhiteSpace(m.Server.Type)) issues.Add(new("server.type", "server.type is required")); + else if (m.Server.Type is not ("python" or "node" or "binary")) issues.Add(new("server.type", "server.type must be one of python|node|binary")); + if (string.IsNullOrWhiteSpace(m.Server.EntryPoint)) issues.Add(new("server.entry_point", "server.entry_point is required")); + if (m.Server.McpConfig == null) issues.Add(new("server.mcp_config", "server.mcp_config is required")); + else if (string.IsNullOrWhiteSpace(m.Server.McpConfig.Command)) issues.Add(new("server.mcp_config.command", "command is required")); + } + + if (Has(m.Version) && !Regex.IsMatch(m.Version, "^\\d+\\.\\d+\\.\\d+")) + issues.Add(new("version", "version should look like MAJOR.MINOR.PATCH")); + + if (m.Author?.Email is string e && e.Length > 0 && !Regex.IsMatch(e, @"^[^@\s]+@[^@\s]+\.[^@\s]+$")) + issues.Add(new("author.email", "invalid email format")); + + void CheckUrl(string? url, string path) + { + if (!string.IsNullOrWhiteSpace(url) && !Uri.TryCreate(url, UriKind.Absolute, out _)) + issues.Add(new(path, "invalid url")); + } + CheckUrl(m.Homepage, "homepage"); + CheckUrl(m.Documentation, "documentation"); + CheckUrl(m.Support, "support"); + if (m.Repository != null) CheckUrl(m.Repository.Url, "repository.url"); + + if (m.Tools != null) + for (int i = 0; i < m.Tools.Count; i++) + if (string.IsNullOrWhiteSpace(m.Tools[i].Name)) issues.Add(new($"tools[{i}].name", "tool name required")); + + if (m.Prompts != null) + for (int i = 0; i < m.Prompts.Count; i++) + { + var prompt = m.Prompts[i]; + if (string.IsNullOrWhiteSpace(prompt.Name)) issues.Add(new($"prompts[{i}].name", "prompt name required")); + if (string.IsNullOrWhiteSpace(prompt.Text)) + { + var message = Has(prompt.Name) + ? $"prompt '{prompt.Name}' text missing from discovery; consider setting text manually in the manifest" + : "prompt text missing from discovery; consider setting text manually in the manifest"; + issues.Add(new($"prompts[{i}].text", message, ValidationSeverity.Warning)); + } + } + + if (m.UserConfig != null) + foreach (var kv in m.UserConfig) + { + var v = kv.Value; + if (string.IsNullOrWhiteSpace(v.Title)) issues.Add(new($"user_config.{kv.Key}.title", "title required")); + if (string.IsNullOrWhiteSpace(v.Description)) issues.Add(new($"user_config.{kv.Key}.description", "description required")); + if (v.Type is not ("string" or "number" or "boolean" or "directory" or "file")) issues.Add(new($"user_config.{kv.Key}.type", "invalid type")); + if (v.Min.HasValue && v.Max.HasValue && v.Min > v.Max) issues.Add(new($"user_config.{kv.Key}", "min cannot exceed max")); + } + + return issues; + } + + // Uses C# nullable metadata attributes to decide if property is nullable (optional) + private static bool IsNullable(System.Reflection.PropertyInfo prop) + { + if (prop.PropertyType.IsValueType) + { + // Value types are required unless Nullable + return Nullable.GetUnderlyingType(prop.PropertyType) != null; + } + // Reference type: inspect NullableAttribute (2 => nullable, 1 => non-nullable) + var nullable = prop.CustomAttributes.FirstOrDefault(a => a.AttributeType.FullName == "System.Runtime.CompilerServices.NullableAttribute"); + if (nullable != null && nullable.ConstructorArguments.Count == 1) + { + var arg = nullable.ConstructorArguments[0]; + if (arg.ArgumentType == typeof(byte)) + { + var flag = (byte)arg.Value!; + return flag == 2; // 2 means nullable + } + if (arg.ArgumentType == typeof(byte[])) + { + var vals = ((IEnumerable)arg.Value!).Select(v => (byte)v.Value!).ToArray(); + if (vals.Length > 0) return vals[0] == 2; + } + } + // Fallback: assume non-nullable (required) + return false; + } + + public static List ValidateJson(string json) + { + using var doc = JsonDocument.Parse(json); + var rootProps = new HashSet(StringComparer.OrdinalIgnoreCase); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + foreach (var p in doc.RootElement.EnumerateObject()) rootProps.Add(p.Name); + var manifest = JsonSerializer.Deserialize(json, Json.McpbJsonContext.Default.McpbManifest)!; + // Fallback: if description property absent in raw JSON but default filled in object, ensure we still treat it as missing. + if (!rootProps.Contains("description") && json.IndexOf("\"description\"", StringComparison.OrdinalIgnoreCase) < 0) + { + // Mark description as intentionally missing by clearing it so required check triggers. + manifest.Description = string.Empty; + } + return Validate(manifest, rootProps); + } +} diff --git a/dotnet/mcpb/Json/JsonContext.cs b/dotnet/mcpb/Json/JsonContext.cs new file mode 100644 index 0000000..8b70a82 --- /dev/null +++ b/dotnet/mcpb/Json/JsonContext.cs @@ -0,0 +1,38 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using Mcpb.Core; + +namespace Mcpb.Json; + +[JsonSerializable(typeof(McpbManifest))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary>))] +[JsonSerializable(typeof(McpbWindowsMeta))] +[JsonSerializable(typeof(McpbStaticResponses))] +[JsonSerializable(typeof(McpbInitializeResult))] +[JsonSerializable(typeof(McpbToolsListResult))] +[JsonSerializable(typeof(System.Text.Json.JsonElement))] +[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +public partial class McpbJsonContext : JsonSerializerContext +{ + private static JsonSerializerOptions? _writeOptions; + + public static JsonSerializerOptions WriteOptions + { + get + { + if (_writeOptions != null) return _writeOptions; + + var options = new JsonSerializerOptions(Default.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + _writeOptions = options; + return options; + } + } +} diff --git a/dotnet/mcpb/Program.cs b/dotnet/mcpb/Program.cs new file mode 100644 index 0000000..96e2eea --- /dev/null +++ b/dotnet/mcpb/Program.cs @@ -0,0 +1,8 @@ +using Mcpb.Commands; +using System.CommandLine; + +var root = CliRoot.Build(); +var invokeResult = await root.InvokeAsync(args); +if (Environment.ExitCode != 0 && invokeResult == 0) + return Environment.ExitCode; +return invokeResult; diff --git a/dotnet/mcpb/mcpb.csproj b/dotnet/mcpb/mcpb.csproj new file mode 100644 index 0000000..6be8ab4 --- /dev/null +++ b/dotnet/mcpb/mcpb.csproj @@ -0,0 +1,50 @@ + + + Exe + net8.0 + enable + enable + Mcpb + mcpb + true + mcpb + Mcpb.Cli + 0.3.0 + Alexander Sklar + CLI tool for building MCP Bundles (.mcpb) + MCP;MCPB;CLI;bundles;DXT;ModelContextProtocol + MIT + https://github.com/asklar/mcpb + https://github.com/asklar/mcpb + README.md + none + false + en + + + + <_Parameter1>mcpb.Tests + + + + + + + + + + + + + + True + True + + + + + + + + +