diff --git a/.github/workflows/publish-dotnet.yml b/.github/workflows/publish-dotnet.yml new file mode 100644 index 0000000..db15f74 --- /dev/null +++ b/.github/workflows/publish-dotnet.yml @@ -0,0 +1,83 @@ +name: Publish .NET Tool + +# This workflow publishes the .NET tool to NuGet using Trusted Publishing (OIDC). +# +# Setup required on NuGet.org: +# 1. Navigate to your package page at https://www.nuget.org/packages/Mcpb.Cli/ +# 2. Go to "Manage Package" and select "Publishing" or "Trusted publishers" +# 3. Click "Add" to configure a new trusted publisher +# 4. Configure the GitHub Actions OIDC settings: +# - Subject Repository: asklar/mcpb +# - Subject Workflow: .github/workflows/publish-dotnet.yml +# - Subject Environment: nuget (optional but recommended) +# 5. Save the trusted publisher configuration +# +# Setup required in GitHub: +# 1. Create an environment named "nuget" in repository settings +# 2. Add protection rules if desired (e.g., required reviewers) +# +# The workflow will run automatically when the version in dotnet/mcpb/mcpb.csproj is changed +# and pushed to the dotnet branch, or it can be triggered manually. + +on: + workflow_dispatch: + push: + branches: + - dotnet + paths: + - 'dotnet/mcpb/mcpb.csproj' + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Publish to NuGet + runs-on: ubuntu-latest + environment: nuget + + 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: Restore dependencies + run: | + cd dotnet + dotnet restore + + - name: Build + run: | + cd dotnet + dotnet build -c Release --no-restore + + - name: Test + run: | + cd dotnet + dotnet test -c Release --no-build --verbosity normal + + - name: Pack + run: | + cd dotnet/mcpb + dotnet pack -c Release --no-build --output ./artifacts + - name: Remove old v2 NuGet source if exists + run: dotnet nuget remove source nuget.org || true + + # Get a short-lived NuGet API key + - name: NuGet login (OIDC → temp API key) + uses: NuGet/login@v1 + id: login + with: + user: ${{ secrets.NUGET_USER }} # Recommended: use a secret like ${{ secrets.NUGET_USER }} for your nuget.org username (profile name), NOT your email address + + # Push the package + - name: NuGet push + run: | + cd dotnet/mcpb + dotnet nuget push ./artifacts/*.nupkg --api-key ${{steps.login.outputs.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json --skip-duplicate + diff --git a/.github/workflows/test-dotnet.yml b/.github/workflows/test-dotnet.yml new file mode 100644 index 0000000..857e2a5 --- /dev/null +++ b/.github/workflows/test-dotnet.yml @@ -0,0 +1,292 @@ +name: Test .NET Tool + +on: + pull_request: + branches: + - main + - user/asklar/dotnet + - dotnet + paths: + - 'dotnet/**' + - 'examples/**' + - '.github/workflows/test-dotnet.yml' + push: + branches: + - main + - dotnet + - 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/README.md b/README.md index f47446c..0d5bad3 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 @@ -135,9 +137,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 @@ -170,3 +173,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/.gitignore b/dotnet/.gitignore new file mode 100644 index 0000000..47a94ef --- /dev/null +++ b/dotnet/.gitignore @@ -0,0 +1,428 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ + +[Dd]ebug/x64/ +[Dd]ebugPublic/x64/ +[Rr]elease/x64/ +[Rr]eleases/x64/ +bin/x64/ +obj/x64/ + +[Dd]ebug/x86/ +[Dd]ebugPublic/x86/ +[Rr]elease/x86/ +[Rr]eleases/x86/ +bin/x86/ +obj/x86/ + +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp diff --git a/dotnet/CLI.md b/dotnet/CLI.md new file mode 100644 index 0000000..ff03fdc --- /dev/null +++ b/dotnet/CLI.md @@ -0,0 +1,402 @@ +# MCPB CLI Documentation + +The MCPB CLI provides tools for building MCP Bundles. + +## Installation + +```bash +npm install -g @anthropic-ai/mcpb +``` + +``` +Usage: mcpb [options] [command] + +Tools for building MCP Bundles + +Options: + -V, --version output the version number + -h, --help display help for command + +Commands: + init [directory] Create a new MCPB extension manifest + 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 + info Display information about a MCPB extension file + unsign Remove signature from a MCPB extension file + help [command] display help for command +``` + +## Commands + +### `mcpb init [directory]` + +Creates a new MCPB extension manifest interactively. + +```bash +# Initialize in current directory +mcpb init + +# Initialize in a specific directory +mcpb init my-extension/ +``` + +The command will prompt you for: + +- Extension name (defaults from package.json or folder name) +- Author name (defaults from package.json) +- Extension ID (auto-generated from author and extension name) +- Display name +- Version (defaults from package.json or 1.0.0) +- Description +- Author email and URL (optional) +- Server type (Node.js, Python, or Binary) +- Entry point (with sensible defaults per server type) +- Tools configuration +- Keywords, license, and repository information + +After creating the manifest, it provides helpful next steps based on your server type. + +### `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. + +```bash +# Validate specific manifest file +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: + +- Always verifies referenced assets relative to the directory (`icon`, each `screenshots` entry, `server.entry_point`, and path-like `server.mcp_config.command`). +- Add `--discover` (which requires `--dirname`) to launch the server, honor `${__dirname}` tokens, and compare discovered tools/prompts against the manifest without mutating it. +- Add `--update` (also requires `--dirname` and cannot be combined with `--discover`) to rewrite the manifest with freshly discovered tools/prompts and associated metadata. + +When `--dirname` is supplied without an explicit manifest argument, the CLI automatically resolves `/manifest.json`. `--discover` fails the command if capability names differ from the manifest, while `--update` rewrites the manifest in-place (setting `tools_generated` / `prompts_generated` and copying tool descriptions plus prompt metadata returned by the server). Without either flag, `mcpb validate` only performs schema and asset checks. + +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. + +#### Providing `user_config` values during validation + +When discovery runs (via `--discover` or `--update`, both of which require `--dirname`), `mcpb validate` enforces required `user_config` entries just like packing. Pass overrides with repeated `--user_config name=value` options. Use the same flag multiple times for a key defined with `"multiple": true` to emit more than one value in order: + +```bash +mcpb validate --dirname . \ + --discover \ + --user_config api_key=sk-123 \ + --user_config allowed_directories=/srv/data \ + --user_config allowed_directories=/srv/docs +``` + +Both spellings `--user_config` and `--user-config` are accepted. If a required entry is missing, the CLI prints the exact flags you still need to supply. + +### `mcpb pack [output]` + +Packs a directory into a MCPB extension file. + +```bash +# Pack current directory into extension.mcpb +mcpb pack . + +# Pack with custom output filename +mcpb pack my-extension/ my-extension-v1.0.mcpb +``` + +The command automatically: + +- Validates the manifest.json +- 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—and whenever `mcpb validate` runs discovery via `--discover` or `--update` (both require `--dirname`)—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). + +`mcpb validate` uses the same comparison logic when discovery is enabled. `--discover` requires `--dirname` and fails if discovered capabilities differ, while `--update` (also requiring `--dirname`) rewrites the manifest and cannot be combined with `--discover`. + +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 (the overrides also apply to `mcpb validate`). + +#### Providing `user_config` values during packing + +If your manifest references `${user_config.*}` tokens, discovery requires real values. Supply them with the `--user_config` (or `--user-config`) flag using `name=value` pairs. Repeat the option to set additional keys or to provide multiple values for entries marked with `"multiple": true` in the manifest. Repeated values for the same key are preserved in order and expand exactly like runtime user input. + +```bash +mcpb pack . \ + --user_config api_key=sk-123 \ + --user_config allowed_directories=/srv/data \ + --user_config allowed_directories=/srv/docs +``` + +Quote the entire `name=value` pair if the value contains spaces (for example, `--user_config "root_dir=C:/My Projects"`). + +#### 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. + +On Windows, `.exe` entry points or path-like commands can be satisfied by [App Execution Aliases](https://learn.microsoft.com/windows/apps/desktop/modernize/desktop-to-uwp-extensions). When a referenced `.exe` is not present under your extension directory, the CLI automatically checks `%LOCALAPPDATA%\Microsoft\WindowsApps` (the folder where aliases surface). To point discovery/validation at custom alias locations—or to simulate aliases in CI—set `MCPB_WINDOWS_APP_ALIAS_DIRS` to a path-separated list of directories. + +When discovery launches your server it resolves the executable using the same logic, so an alias that passes validation is the exact binary that will be executed. + +Commands (e.g. `node`, `python`) that are not path-like are not validated—they are treated as executables resolved by the environment. + +Examples: + +```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. + +```bash +# Sign with default certificate paths +mcpb sign my-extension.mcpb + +# Sign with custom certificate and key +mcpb sign my-extension.mcpb --cert /path/to/cert.pem --key /path/to/key.pem + +# Sign with intermediate certificates +mcpb sign my-extension.mcpb --cert cert.pem --key key.pem --intermediate intermediate1.pem intermediate2.pem + +# Create and use a self-signed certificate +mcpb sign my-extension.mcpb --self-signed +``` + +Options: + +- `--cert, -c`: Path to certificate file (PEM format, default: cert.pem) +- `--key, -k`: Path to private key file (PEM format, default: key.pem) +- `--intermediate, -i`: Paths to intermediate certificate files +- `--self-signed`: Create a self-signed certificate if none exists + +### `mcpb verify ` + +Verifies the signature of a signed MCPB extension file. + +```bash +mcpb verify my-extension.mcpb +``` + +Output includes: + +- Signature validity status +- Certificate subject and issuer +- Certificate validity dates +- Certificate fingerprint +- Warning if self-signed + +### `mcpb info ` + +Displays information about a MCPB extension file. + +```bash +mcpb info my-extension.mcpb +``` + +Shows: + +- File size +- Signature status +- Certificate details (if signed) + +### `mcpb unsign ` + +Removes the signature from a MCPB extension file (for development/testing). + +```bash +mcpb unsign my-extension.mcpb +``` + +## Certificate Requirements + +For signing extensions, you need: + +1. **Certificate**: X.509 certificate in PEM format + - Should have Code Signing extended key usage + - Can be self-signed (for development) or CA-issued (for production) + +2. **Private Key**: Corresponding private key in PEM format + - Must match the certificate's public key + +3. **Intermediate Certificates** (optional): For CA-issued certificates + - Required for proper certificate chain validation + +## Example Workflows + +### Quick Start with Init + +```bash +# 1. Create a new extension directory +mkdir my-awesome-extension +cd my-awesome-extension + +# 2. Initialize the extension +mcpb init + +# 3. Follow the prompts to configure your extension +# The tool will create a manifest.json with all necessary fields + +# 4. Create your server implementation based on the entry point you specified + +# 5. Pack the extension +mcpb pack . + +# 6. (Optional) Sign the extension +mcpb sign my-awesome-extension.mcpb --self-signed +``` + +### Development Workflow + +```bash +# 1. Create your extension +mkdir my-extension +cd my-extension + +# 2. Initialize with mcpb init or create manifest.json manually +mcpb init + +# 3. Implement your server +# For Node.js: create server/index.js +# For Python: create server/main.py +# For Binary: add your executable + +# 4. Validate manifest +mcpb validate manifest.json + +# 5. Pack extension +mcpb pack . my-extension.mcpb + +# 6. (Optional) Sign for testing +mcpb sign my-extension.mcpb --self-signed + +# 7. Verify signature +mcpb verify my-extension.mcpb + +# 8. Check extension info +mcpb info my-extension.mcpb +``` + +### Production Workflow + +```bash +# 1. Pack your extension +mcpb pack my-extension/ + +# 2. Sign with production certificate +mcpb sign my-extension.mcpb \ + --cert production-cert.pem \ + --key production-key.pem \ + --intermediate intermediate-ca.pem root-ca.pem + +# 3. Verify before distribution +mcpb verify my-extension.mcpb +``` + +## Excluded Files + +When packing an extension, the following files/patterns are automatically excluded: + +- `.DS_Store`, `Thumbs.db` +- `.gitignore`, `.git/` +- `*.log`, `npm-debug.log*`, `yarn-debug.log*`, `yarn-error.log*` +- `.npm/`, `.npmrc`, `.yarnrc`, `.yarn/`, `.pnp.*` +- `node_modules/.cache/`, `node_modules/.bin/` +- `*.map` +- `.env.local`, `.env.*.local` +- `package-lock.json`, `yarn.lock` + +### Custom Exclusions with .mcpbignore + +You can create a `.mcpbignore` file in your extension directory to specify additional files and patterns to exclude during packing. This works similar to `.npmignore` or `.gitignore`: + +``` +# .mcpbignore example +# Comments start with # +*.test.js +src/**/*.test.ts +coverage/ +*.log +.env* +temp/ +docs/ +``` + +The `.mcpbignore` file supports: + +- **Exact matches**: `filename.txt` +- **Simple globs**: `*.log`, `temp/*` +- **Directory paths**: `docs/`, `coverage/` +- **Comments**: Lines starting with `#` are ignored +- **Empty lines**: Blank lines are ignored + +When a `.mcpbignore` file is found, the CLI will display the number of additional patterns being applied. These patterns are combined with the default exclusion list. + +## Technical Details + +### Signature Format + +MCPB uses PKCS#7 (Cryptographic Message Syntax) for digital signatures: + +- Signatures are stored in DER-encoded PKCS#7 SignedData format +- The signature is appended to the MCPB file with markers (`MCPB_SIG_V1` and `MCPB_SIG_END`) +- The entire MCPB content (excluding the signature block) is signed +- Detached signature format - the original ZIP content remains unmodified + +### Signature Structure + +``` +[Original MCPB ZIP content] +MCPB_SIG_V1 +[Base64-encoded PKCS#7 signature] +MCPB_SIG_END +``` + +This approach allows: + +- Backward compatibility (unsigned MCPB files are valid ZIP files) +- Easy signature verification and removal +- Support for certificate chains with intermediate certificates diff --git a/dotnet/CONTRIBUTING.md b/dotnet/CONTRIBUTING.md new file mode 100644 index 0000000..bb5f094 --- /dev/null +++ b/dotnet/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Contributing to the MCPB .NET CLI + +Before submitting changes, read the repository-wide `../CONTRIBUTING.md` for coding standards and pull request expectations. The notes below capture .NET-specific workflows for building, testing, and installing the CLI locally. + +## Prerequisites + +- .NET 8 SDK +- PowerShell (the examples use `pwsh` syntax) + +## Build from Source + +```pwsh +cd dotnet/mcpb +dotnet build -c Release +``` + +Use `dotnet test mcpb.slnx` from the `dotnet` folder to run the full test suite. + +## Install as a Local Tool + +When iterating locally you can pack the CLI and install it from the generated `.nupkg` instead of a public feed: + +```pwsh +cd dotnet/mcpb +dotnet pack -c Release +# Find generated nupkg in bin/Release +dotnet tool install --global Mcpb.Cli --add-source ./bin/Release +``` + +If you already have the tool installed, update it in place: + +```pwsh +dotnet tool update --global Mcpb.Cli --add-source ./bin/Release +``` + +## Working on Documentation + +The cross-platform CLI behavior is described in the root-level `CLI.md`. When you update .NET-specific behaviors or options, mirror those edits in that document (and any relevant tests) so the Node and .NET toolchains stay aligned. diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 0000000..1e5af84 --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,58 @@ +# MCPB .NET CLI + +Experimental .NET port of the MCPB CLI. It mirrors the Node-based tool while layering the Windows-specific metadata required for the Windows On-Device Registry, so you can validate, pack, and sign MCP Bundles directly with the .NET tooling stack. + +## Quick Usage + +Install the CLI globally and walk through the workflow in a single PowerShell session: + +```pwsh +dotnet tool install -g mcpb.cli + +# 1. Create a manifest (or edit an existing one) +mcpb init my-extension + +# 2. Validate assets and discovered capabilities +mcpb validate --dirname my-extension --discover \ + --user_config api_key=sk-123 \ + --user_config allowed_directories=/srv/data + +# 3. Produce the bundle +mcpb pack my-extension --update + +# 4. (Optional) Sign and inspect +mcpb sign my-extension.mcpb --self-signed +mcpb info my-extension.mcpb +``` + +For complete CLI behavior details, see the root-level `CLI.md` guide. + +## Command Cheatsheet + +| Command | Description | +| --------------------------------------------------------------------------------------- | ------------------------------------ | +| `mcpb init [directory] [--server-type node\|python\|binary\|auto] [--entry-point path]` | Create or update `manifest.json` | +| `mcpb validate [manifest\|directory] [--dirname path] [--discover] [--update] [--verbose]` | Validate manifests and referenced assets | +| `mcpb pack [directory] [output]` | Create an `.mcpb` archive | +| `mcpb unpack [outputDir]` | Extract an archive | +| `mcpb sign [--cert cert.pem --key key.pem --self-signed]` | Sign the bundle | +| `mcpb verify ` | Verify a signature | +| `mcpb info ` | Show archive & signature metadata | +| `mcpb unsign ` | Remove a signature block | + +## Windows `_meta` Updates + +When you run `mcpb validate --update` or `mcpb pack --update`, the tool captures the Windows-focused initialize and tools/list responses returned during MCP discovery. The static responses are written to `manifest._meta["com.microsoft.windows"].static_responses` so Windows clients can use cached protocol data without invoking the server. Re-run either command with `--update` whenever you want to refresh those cached responses. + +## Validation Modes + +- `--discover` runs capability discovery without rewriting the manifest. It exits with a non-zero status if discovered tools or prompts differ from the manifest, which is helpful for CI checks. +- `--verbose` prints each validation step, including the files and locale resources being verified, so you can diagnose failures quickly. + +## Need to Build or Contribute? + +Development and installation-from-source steps now live in `CONTRIBUTING.md` within this directory. It also points to the repository-wide `../CONTRIBUTING.md` guide for pull request expectations. + +## 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..9c13ca4 --- /dev/null +++ b/dotnet/mcpb.Tests/CliPackFileValidationTests.cs @@ -0,0 +1,331 @@ +using System; +using System.IO; +using System.Text.Json; +using Mcpb.Json; +using Xunit; + +namespace Mcpb.Tests; + +public class CliPackFileValidationTests +{ + private string CreateTempDir() + { + var dir = Path.Combine( + Path.GetTempPath(), + "mcpb_cli_pack_files_" + Guid.NewGuid().ToString("N") + ); + 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_CommandWindowsAlias_Succeeds() + { + var aliasDir = Path.Combine( + Path.GetTempPath(), + "mcpb_windows_alias_" + Guid.NewGuid().ToString("N") + ); + Directory.CreateDirectory(aliasDir); + var aliasName = "alias-command.exe"; + File.WriteAllText(Path.Combine(aliasDir, aliasName), "alias"); + var previousAliases = Environment.GetEnvironmentVariable("MCPB_WINDOWS_APP_ALIAS_DIRS"); + Environment.SetEnvironmentVariable("MCPB_WINDOWS_APP_ALIAS_DIRS", aliasDir); + try + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); + File.WriteAllText(Path.Combine(dir, "server", "index.js"), "// js"); + Directory.CreateDirectory(Path.Combine(dir, "shots")); + File.WriteAllText(Path.Combine(dir, "shots", "s1.png"), "fake"); + var manifest = BaseManifest(); + manifest.Server.McpConfig.Command = aliasName; + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); + Assert.Equal(0, code); + Assert.Contains("demo@", stdout); + Assert.DoesNotContain("Missing server.command", stderr); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_WINDOWS_APP_ALIAS_DIRS", previousAliases); + try + { + Directory.Delete(aliasDir, true); + } + catch + { + // Ignore cleanup failures in tests + } + } + } + + [Fact] + public void Pack_EntryPointWindowsAlias_Succeeds() + { + var aliasDir = Path.Combine( + Path.GetTempPath(), + "mcpb_windows_alias_" + Guid.NewGuid().ToString("N") + ); + Directory.CreateDirectory(aliasDir); + var aliasName = "alias-entry.exe"; + File.WriteAllText(Path.Combine(aliasDir, aliasName), "alias"); + var previousAliases = Environment.GetEnvironmentVariable("MCPB_WINDOWS_APP_ALIAS_DIRS"); + Environment.SetEnvironmentVariable("MCPB_WINDOWS_APP_ALIAS_DIRS", aliasDir); + try + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); + File.WriteAllText(Path.Combine(dir, "server", "index.js"), "// js"); + Directory.CreateDirectory(Path.Combine(dir, "shots")); + File.WriteAllText(Path.Combine(dir, "shots", "s1.png"), "fake"); + var manifest = BaseManifest(); + manifest.Server.EntryPoint = aliasName; + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); + Assert.Equal(0, code); + Assert.Contains("demo@", stdout); + Assert.DoesNotContain("Missing entry_point", stderr); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_WINDOWS_APP_ALIAS_DIRS", previousAliases); + try + { + Directory.Delete(aliasDir, true); + } + catch + { + // Ignore cleanup failures in tests + } + } + } + + [Fact] + public void Pack_AllFilesPresent_Succeeds() + { + 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); + } + + [Fact] + public void Pack_MissingIconsFile_Fails() + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); + File.WriteAllText(Path.Combine(dir, "server", "index.js"), "// js"); + var manifest = BaseManifest(); + manifest.ManifestVersion = "0.3"; + manifest.Icons = new List + { + new() { Src = "icon-16.png", Size = "16x16" }, + }; + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); + var (code, _, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); + Assert.NotEqual(0, code); + Assert.Contains("Missing icons[0] file", stderr); + } + + [Fact] + public void Pack_IconsFilePresent_Succeeds() + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); + File.WriteAllText(Path.Combine(dir, "icon-16.png"), "fake16"); + File.WriteAllText(Path.Combine(dir, "server", "index.js"), "// js"); + var manifest = BaseManifest(); + manifest.ManifestVersion = "0.3"; + manifest.Screenshots = null; // Remove screenshots requirement for this test + manifest.Icons = new List + { + new() { Src = "icon-16.png", Size = "16x16" }, + }; + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); + Assert.True(code == 0, $"Pack failed with code {code}. Stderr: {stderr}"); + Assert.Contains("demo@", stdout); + } + + [Fact] + public void Pack_MissingLocalizationResources_Fails() + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); + File.WriteAllText(Path.Combine(dir, "server", "index.js"), "// js"); + var manifest = BaseManifest(); + manifest.ManifestVersion = "0.3"; + manifest.Localization = new Mcpb.Core.McpbManifestLocalization + { + Resources = "locales/${locale}/messages.json", + DefaultLocale = "en-US", + }; + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); + var (code, _, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); + Assert.NotEqual(0, code); + Assert.Contains("Missing localization resources", stderr); + } + + [Fact] + public void Pack_LocalizationResourcesPresent_Succeeds() + { + var dir = CreateTempDir(); + File.WriteAllText(Path.Combine(dir, "icon.png"), "fake"); + File.WriteAllText(Path.Combine(dir, "server", "index.js"), "// js"); + Directory.CreateDirectory(Path.Combine(dir, "locales", "en-US")); + File.WriteAllText(Path.Combine(dir, "locales", "en-US", "messages.json"), "{}"); + var manifest = BaseManifest(); + manifest.ManifestVersion = "0.3"; + manifest.Screenshots = null; // Remove screenshots requirement for this test + manifest.Localization = new Mcpb.Core.McpbManifestLocalization + { + Resources = "locales/${locale}/messages.json", + DefaultLocale = "en-US", + }; + File.WriteAllText( + Path.Combine(dir, "manifest.json"), + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover"); + Assert.True(code == 0, $"Pack failed with code {code}. Stderr: {stderr}"); + Assert.Contains("demo@", stdout); + } +} diff --git a/dotnet/mcpb.Tests/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/CliPackUserConfigDiscoveryTests.cs b/dotnet/mcpb.Tests/CliPackUserConfigDiscoveryTests.cs new file mode 100644 index 0000000..ef2ae91 --- /dev/null +++ b/dotnet/mcpb.Tests/CliPackUserConfigDiscoveryTests.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Mcpb.Core; +using Mcpb.Json; +using Xunit; + +namespace Mcpb.Tests; + +public class CliPackUserConfigDiscoveryTests +{ + private string CreateTempDir() + { + var dir = Path.Combine( + Path.GetTempPath(), + "mcpb_cli_pack_uc_" + 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 previous = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(workingDir); + using var stdoutWriter = new StringWriter(); + using var stderrWriter = new StringWriter(); + try + { + var code = CommandRunner.Invoke(root, args, stdoutWriter, stderrWriter); + return (code, stdoutWriter.ToString(), stderrWriter.ToString()); + } + finally + { + Directory.SetCurrentDirectory(previous); + } + } + + private McpbManifest CreateManifest() + { + return new McpbManifest + { + Name = "demo", + Description = "desc", + Author = new McpbManifestAuthor { Name = "Author" }, + Server = new McpbManifestServer + { + Type = "node", + EntryPoint = "server/index.js", + McpConfig = new McpServerConfigWithOverrides + { + Command = "node", + Args = new List + { + "${__dirname}/server/index.js", + "--api-key=${user_config.api_key}", + }, + }, + }, + UserConfig = new Dictionary + { + ["api_key"] = new McpbUserConfigOption + { + Title = "API Key", + Description = "API key for the service", + Type = "string", + Required = true, + }, + }, + Tools = new List(), + }; + } + + private McpbManifest CreateMultiValueManifest() + { + return new McpbManifest + { + Name = "multi", + Description = "multi", + Author = new McpbManifestAuthor { Name = "Author" }, + Server = new McpbManifestServer + { + Type = "node", + EntryPoint = "server/index.js", + McpConfig = new McpServerConfigWithOverrides + { + Command = "node", + Args = new List + { + "${__dirname}/server/index.js", + "--allow", + "${user_config.allowed_directories}", + }, + }, + }, + UserConfig = new Dictionary + { + ["allowed_directories"] = new McpbUserConfigOption + { + Title = "Dirs", + Description = "Allowed directories", + Type = "directory", + Required = true, + Multiple = true, + }, + }, + Tools = new List(), + }; + } + + private void WriteManifest(string dir, McpbManifest manifest) + { + var manifestPath = Path.Combine(dir, "manifest.json"); + File.WriteAllText( + manifestPath, + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); + } + + private void WriteServerFiles(string dir) + { + var serverDir = Path.Combine(dir, "server"); + Directory.CreateDirectory(serverDir); + File.WriteAllText(Path.Combine(serverDir, "index.js"), "console.log('hello');"); + } + + [Fact] + public void Pack_DiscoveryFails_WhenRequiredUserConfigMissing() + { + var dir = CreateTempDir(); + WriteServerFiles(dir); + WriteManifest(dir, CreateManifest()); + + var (code, stdout, stderr) = InvokeCli(dir, "pack", dir, "--no-discover=false"); + + Assert.NotEqual(0, code); + var combined = stdout + stderr; + Assert.Contains("user_config", combined, StringComparison.OrdinalIgnoreCase); + Assert.Contains("api_key", combined, StringComparison.OrdinalIgnoreCase); + Assert.Contains("--user_config", combined, StringComparison.Ordinal); + } + + [Fact] + public void Pack_DiscoverySucceeds_WhenUserConfigProvided() + { + var dir = CreateTempDir(); + WriteServerFiles(dir); + WriteManifest(dir, CreateManifest()); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[]"); + try + { + var (code, stdout, stderr) = InvokeCli( + dir, + "pack", + dir, + "--user_config", + "api_key=secret", + "--no-discover=false" + ); + + Assert.Equal(0, code); + Assert.Contains("demo@", stdout); + Assert.DoesNotContain("user_config", stderr, StringComparison.OrdinalIgnoreCase); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + } + } + + [Fact] + public void Pack_Discovery_ExpandsMultipleUserConfigValues() + { + var dir = CreateTempDir(); + WriteServerFiles(dir); + WriteManifest(dir, CreateMultiValueManifest()); + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", "[]"); + try + { + var (code, stdout, stderr) = InvokeCli( + dir, + "pack", + dir, + "--user_config", + "allowed_directories=/data/a", + "--user_config", + "allowed_directories=/data/b", + "--no-discover=false" + ); + + Assert.Equal(0, code); + var normalizedStdout = stdout.Replace('\\', '/'); + Assert.Contains("/data/a /data/b", normalizedStdout); + Assert.DoesNotContain("user_config", stderr, StringComparison.OrdinalIgnoreCase); + } + 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..14bc4bb --- /dev/null +++ b/dotnet/mcpb.Tests/CliValidateTests.cs @@ -0,0 +1,522 @@ +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_DiscoverDetectsStaticResponsesMismatch() + { + var dir = CreateTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "server")); + File.WriteAllText(Path.Combine(dir, "server", "demo"), "binary"); + + var manifest = new Mcpb.Core.McpbManifest + { + Name = "meta", + 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 = "dummy", Description = "fake" } }, + Prompts = new List { new() { Name = "prompt", Description = "desc", Text = "body" } } + }; + + 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\":\"prompt\",\"description\":\"desc\",\"text\":\"body\"}]"); + Environment.SetEnvironmentVariable("MCPB_INITIALIZE_DISCOVERY_JSON", "{\"protocolVersion\":\"2025-01-01\",\"serverInfo\":{\"name\":\"test-server\"}}"); + Environment.SetEnvironmentVariable("MCPB_TOOLS_LIST_DISCOVERY_JSON", "{\"tools\":[{\"name\":\"dummy\",\"description\":\"fake\"}]}"); + try + { + var (code, stdout, stderr) = InvokeCli(dir, "validate", "manifest.json", "--dirname", dir, "--discover"); + _output.WriteLine("STDOUT: " + stdout); + _output.WriteLine("STDERR: " + stderr); + Assert.NotEqual(0, code); + Assert.Contains("static_responses", stdout + stderr, StringComparison.OrdinalIgnoreCase); + Assert.Contains("tools/list", stdout + stderr, StringComparison.OrdinalIgnoreCase); + } + finally + { + Environment.SetEnvironmentVariable("MCPB_TOOL_DISCOVERY_JSON", null); + Environment.SetEnvironmentVariable("MCPB_PROMPT_DISCOVERY_JSON", null); + Environment.SetEnvironmentVariable("MCPB_INITIALIZE_DISCOVERY_JSON", null); + Environment.SetEnvironmentVariable("MCPB_TOOLS_LIST_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); + Assert.Contains("--discover", stdout + stderr, StringComparison.OrdinalIgnoreCase); + } + 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..20cca95 --- /dev/null +++ b/dotnet/mcpb.Tests/ManifestValidatorTests.cs @@ -0,0 +1,233 @@ +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); + } + + [Fact] + public void ValidLocalization_Passes() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Localization = new McpbManifestLocalization + { + Resources = "locales/${locale}/messages.json", + DefaultLocale = "en-US" + }; + var issues = ManifestValidator.Validate(m); + Assert.Empty(issues); + } + + [Fact] + public void LocalizationWithDefaults_Passes() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + // Resources and DefaultLocale are optional with defaults + m.Localization = new McpbManifestLocalization + { + Resources = null, // defaults to "mcpb-resources/${locale}.json" + DefaultLocale = null // defaults to "en-US" + }; + var issues = ManifestValidator.Validate(m); + Assert.Empty(issues); + } + + [Fact] + public void LocalizationResourcesWithoutPlaceholder_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Localization = new McpbManifestLocalization + { + Resources = "locales/messages.json", + DefaultLocale = "en-US" + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "localization.resources" && i.Message.Contains("placeholder")); + } + + [Fact] + public void LocalizationEmptyObject_PassesWithDefaults() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + // Empty localization object should use defaults + m.Localization = new McpbManifestLocalization(); + var issues = ManifestValidator.Validate(m); + Assert.Empty(issues); + } + + [Fact] + public void LocalizationInvalidDefaultLocale_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Localization = new McpbManifestLocalization + { + Resources = "locales/${locale}/messages.json", + DefaultLocale = "invalid locale" + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "localization.default_locale" && i.Message.Contains("BCP 47")); + } + + [Fact] + public void ValidIcons_Passes() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + new() { Src = "icon-16.png", Size = "16x16" }, + new() { Src = "icon-32.png", Size = "32x32", Theme = "light" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Empty(issues); + } + + [Fact] + public void IconMissingSrc_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + new() { Src = "", Size = "16x16" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "icons[0].src"); + } + + [Fact] + public void IconMissingSize_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + new() { Src = "icon.png", Size = "" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "icons[0].size"); + } + + [Fact] + public void IconInvalidSizeFormat_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + new() { Src = "icon.png", Size = "16" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "icons[0].size" && i.Message.Contains("WIDTHxHEIGHT")); + } + + [Fact] + public void IconEmptyTheme_Fails() + { + var m = BaseManifest(); + m.ManifestVersion = "0.3"; + m.Icons = new List + { + new() { Src = "icon.png", Size = "16x16", Theme = "" } + }; + var issues = ManifestValidator.Validate(m); + Assert.Contains(issues, i => i.Path == "icons[0].theme" && i.Message.Contains("empty")); + } +} diff --git a/dotnet/mcpb.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..c40276d --- /dev/null +++ b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs @@ -0,0 +1,2163 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Mcpb.Core; +using Mcpb.Json; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Mcpb.Commands; + +internal static class ManifestCommandHelpers +{ + private const string WindowsAppAliasDirectoriesEnvVar = "MCPB_WINDOWS_APP_ALIAS_DIRS"; + private static readonly TimeSpan DiscoveryTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan DiscoveryInitializationTimeout = TimeSpan.FromSeconds(15); + private static readonly IReadOnlyDictionary< + string, + IReadOnlyList + > EmptyUserConfigOverrides = new Dictionary>( + StringComparer.Ordinal + ); + private static readonly Regex UserConfigTokenRegex = new( + "\\$\\{user_config\\.([^}]+)\\}", + RegexOptions.IgnoreCase | RegexOptions.Compiled + ); + + internal sealed class UserConfigRequiredException : InvalidOperationException + { + public UserConfigRequiredException(string message) + : base(message) { } + } + + internal record CapabilityDiscoveryResult( + List Tools, + List Prompts, + McpbInitializeResult? InitializeResponse, + McpbToolsListResult? ToolsListResponse, + string? ReportedServerName, + string? ReportedServerVersion + ); + + internal record CapabilityComparisonResult( + bool NamesDiffer, + bool MetadataDiffer, + List SummaryTerms, + List Messages + ) + { + public bool HasDifferences => NamesDiffer || MetadataDiffer; + } + + internal record StaticResponseComparisonResult( + bool InitializeDiffers, + bool ToolsListDiffers, + List SummaryTerms, + List Messages + ) + { + public bool HasDifferences => InitializeDiffers || ToolsListDiffers; + } + + /// + /// 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, + Action? verboseLog = null + ) + { + var errors = new List(); + if (manifest.Server == null) + { + errors.Add("Manifest server configuration missing"); + return errors; + } + + verboseLog?.Invoke("Checking referenced files and assets"); + + static bool IsSystem32Path(string value, out string normalizedAbsolute) + { + normalizedAbsolute = string.Empty; + if (string.IsNullOrWhiteSpace(value)) + return false; + try + { + var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + if (string.IsNullOrWhiteSpace(windowsDir)) + return false; + var candidate = value.Replace('/', '\\'); + if (!Path.IsPathRooted(candidate)) + return false; + var full = Path.GetFullPath(candidate); + var system32 = Path.Combine(windowsDir, "System32"); + if (full.StartsWith(system32, StringComparison.OrdinalIgnoreCase)) + { + normalizedAbsolute = full; + return true; + } + } + catch + { + return false; + } + return false; + } + + bool TryResolveManifestPath(string rawPath, string category, out string resolved) + { + resolved = string.Empty; + if (IsSystem32Path(rawPath, out var systemPath)) + { + resolved = systemPath; + return true; + } + + if (rawPath.StartsWith('/') || rawPath.StartsWith('\\')) + { + errors.Add($"{category} path must be relative and use '/' separators: {rawPath}"); + return false; + } + if (Path.IsPathRooted(rawPath)) + { + errors.Add( + $"{category} path must be relative or reside under Windows\\System32: {rawPath}" + ); + return false; + } + if (rawPath.Contains('\\')) + { + errors.Add($"{category} path must use '/' as directory separator: {rawPath}"); + return false; + } + + resolved = Resolve(rawPath); + return true; + } + + 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, bool allowAliasResolution = false) + { + if (string.IsNullOrWhiteSpace(relativePath)) + return; + if (!TryResolveManifestPath(relativePath, category, out var resolved)) + { + return; + } + verboseLog?.Invoke($"Ensuring {category} file exists: {relativePath} -> {resolved}"); + if (!File.Exists(resolved)) + { + var trimmed = relativePath.Trim(); + if ( + allowAliasResolution + && TryResolveWindowsAppExecutionAlias(trimmed, out var aliasPath) + ) + { + verboseLog?.Invoke( + $"Resolved {category} '{trimmed}' via Windows app execution alias: {aliasPath}" + ); + } + else + { + errors.Add($"Missing {category} file: {relativePath}"); + } + } + } + + if (!string.IsNullOrWhiteSpace(manifest.Icon)) + { + CheckFile(manifest.Icon, "icon"); + } + + if (!string.IsNullOrWhiteSpace(manifest.Server.EntryPoint)) + { + verboseLog?.Invoke($"Checking server entry point {manifest.Server.EntryPoint}"); + CheckFile(manifest.Server.EntryPoint, "entry_point", allowAliasResolution: true); + } + + var command = manifest.Server.McpConfig?.Command; + if (!string.IsNullOrWhiteSpace(command)) + { + var cmd = command!; + verboseLog?.Invoke($"Resolving server command {cmd}"); + bool pathLike = IsCommandPathLike(cmd); + if (pathLike) + { + var expanded = ExpandToken(cmd, baseDir); + var normalized = NormalizePathForPlatform(expanded); + var resolved = normalized; + if (!Path.IsPathRooted(normalized)) + { + resolved = Path.Combine(baseDir, normalized); + } + verboseLog?.Invoke($"Ensuring server command file exists: {resolved}"); + if (!File.Exists(resolved)) + { + if (TryResolveWindowsAppExecutionAlias(cmd, out var aliasPath)) + { + verboseLog?.Invoke( + $"Resolved server command '{cmd}' via Windows app execution alias: {aliasPath}" + ); + } + else + { + errors.Add($"Missing server.command file: {command}"); + } + } + } + } + + if (manifest.Screenshots != null) + { + foreach (var shot in manifest.Screenshots) + { + if (string.IsNullOrWhiteSpace(shot)) + continue; + verboseLog?.Invoke($"Checking screenshot {shot}"); + CheckFile(shot, "screenshot"); + } + } + + if (manifest.Icons != null) + { + for (int i = 0; i < manifest.Icons.Count; i++) + { + var icon = manifest.Icons[i]; + if (!string.IsNullOrWhiteSpace(icon.Src)) + { + verboseLog?.Invoke($"Checking icon {icon.Src}"); + CheckFile(icon.Src, $"icons[{i}]"); + } + } + } + + if (manifest.Localization != null) + { + // Check if the localization resources path exists + // Resources defaults to "mcpb-resources/${locale}.json" if not specified + var resourcePath = manifest.Localization.Resources ?? "mcpb-resources/${locale}.json"; + // DefaultLocale defaults to "en-US" if not specified + var defaultLocale = manifest.Localization.DefaultLocale ?? "en-US"; + + var defaultLocalePath = resourcePath.Replace( + "${locale}", + defaultLocale, + StringComparison.OrdinalIgnoreCase + ); + var resolved = Resolve(defaultLocalePath); + verboseLog?.Invoke( + $"Ensuring localization resources exist for default locale at {resolved}" + ); + + // Check if it's a file or directory + if (!File.Exists(resolved) && !Directory.Exists(resolved)) + { + errors.Add( + $"Missing localization resources for default locale: {defaultLocalePath}" + ); + } + } + + return errors; + } + + private static bool TryResolveWindowsAppExecutionAlias( + string? candidate, + out string resolvedPath + ) + { + resolvedPath = string.Empty; + if (string.IsNullOrWhiteSpace(candidate)) + return false; + + var trimmed = candidate.Trim(); + if (trimmed.Length == 0) + return false; + + if ( + trimmed.IndexOf('/') >= 0 + || trimmed.IndexOf('\\') >= 0 + || !trimmed.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ) + { + return false; + } + + foreach (var directory in EnumerateWindowsAppAliasDirectories()) + { + try + { + var path = Path.Combine(directory, trimmed); + if (File.Exists(path)) + { + resolvedPath = path; + return true; + } + } + catch + { + // Ignore issues accessing alias directories + } + } + + return false; + } + + private static IEnumerable EnumerateWindowsAppAliasDirectories() + { + var overrideValue = Environment.GetEnvironmentVariable(WindowsAppAliasDirectoriesEnvVar); + var reported = new HashSet(StringComparer.OrdinalIgnoreCase); + + void Add(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return; + try + { + var full = Path.GetFullPath(path); + if (!Directory.Exists(full)) + return; + full = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (reported.Add(full)) + { + // nothing else to do + } + } + catch + { + // Ignore invalid directories + } + } + + if (!string.IsNullOrWhiteSpace(overrideValue)) + { + var splits = overrideValue.Split( + Path.PathSeparator, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ); + foreach (var part in splits) + { + Add(part); + } + } + else if (OperatingSystem.IsWindows()) + { + var localAppData = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData + ); + if (!string.IsNullOrWhiteSpace(localAppData)) + { + Add(Path.Combine(localAppData, "Microsoft", "WindowsApps")); + } + } + + foreach (var dir in reported) + { + yield return dir; + } + } + + private static bool IsCommandPathLike(string command) + { + if (string.IsNullOrWhiteSpace(command)) + return false; + var trimmed = command.Trim(); + return trimmed.Contains('/') + || trimmed.Contains('\\') + || trimmed.StartsWith("${__dirname}", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("./") + || trimmed.StartsWith("..") + || trimmed.EndsWith(".js", StringComparison.OrdinalIgnoreCase) + || trimmed.EndsWith(".py", StringComparison.OrdinalIgnoreCase) + || trimmed.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); + } + + internal static List ValidateLocalizationCompleteness( + McpbManifest manifest, + string baseDir, + HashSet? rootProps = null, + Action? verboseLog = null + ) + { + var errors = new List(); + + if (manifest.Localization == null) + return errors; + + verboseLog?.Invoke("Checking localization completeness across locales"); + + // Get the resource path pattern and default locale + var resourcePath = manifest.Localization.Resources ?? "mcpb-resources/${locale}.json"; + var defaultLocale = manifest.Localization.DefaultLocale ?? "en-US"; + + // Determine localizable properties present in the manifest + // Only check properties that were explicitly set in the JSON + var localizableProperties = new List(); + if (rootProps != null) + { + if ( + rootProps.Contains("display_name") + && !string.IsNullOrWhiteSpace(manifest.DisplayName) + ) + localizableProperties.Add("display_name"); + if ( + rootProps.Contains("description") + && !string.IsNullOrWhiteSpace(manifest.Description) + ) + localizableProperties.Add("description"); + if ( + rootProps.Contains("long_description") + && !string.IsNullOrWhiteSpace(manifest.LongDescription) + ) + localizableProperties.Add("long_description"); + if (rootProps.Contains("author") && !string.IsNullOrWhiteSpace(manifest.Author?.Name)) + localizableProperties.Add("author.name"); + if ( + rootProps.Contains("keywords") + && manifest.Keywords != null + && manifest.Keywords.Count > 0 + ) + localizableProperties.Add("keywords"); + } + else + { + // Fallback if rootProps not provided + if (!string.IsNullOrWhiteSpace(manifest.DisplayName)) + localizableProperties.Add("display_name"); + if (!string.IsNullOrWhiteSpace(manifest.Description)) + localizableProperties.Add("description"); + if (!string.IsNullOrWhiteSpace(manifest.LongDescription)) + localizableProperties.Add("long_description"); + if (!string.IsNullOrWhiteSpace(manifest.Author?.Name)) + localizableProperties.Add("author.name"); + if (manifest.Keywords != null && manifest.Keywords.Count > 0) + localizableProperties.Add("keywords"); + } + + // Also check tool and prompt descriptions + var toolsWithDescriptions = + manifest.Tools?.Where(t => !string.IsNullOrWhiteSpace(t.Description)).ToList() + ?? new List(); + var promptsWithDescriptions = + manifest.Prompts?.Where(p => !string.IsNullOrWhiteSpace(p.Description)).ToList() + ?? new List(); + + if ( + localizableProperties.Count == 0 + && toolsWithDescriptions.Count == 0 + && promptsWithDescriptions.Count == 0 + ) + return errors; // Nothing to localize + + // Find all locale files by scanning the directory pattern + var localeFiles = FindLocaleFiles(resourcePath, baseDir, defaultLocale); + + if (localeFiles.Count == 0) + return errors; // No additional locale files found, nothing to validate + + // Check each locale file for completeness + foreach (var (locale, filePath) in localeFiles) + { + if (locale == defaultLocale) + continue; // Skip default locale (values are in main manifest) + + verboseLog?.Invoke($"Validating localization file {filePath} for locale {locale}"); + try + { + if (!File.Exists(filePath)) + { + errors.Add($"Locale file not found: {filePath} (for locale {locale})"); + continue; + } + + var localeJson = File.ReadAllText(filePath); + var localeResource = JsonSerializer.Deserialize( + localeJson, + McpbJsonContext.Default.McpbLocalizationResource + ); + + if (localeResource == null) + { + errors.Add($"Failed to parse locale file: {filePath}"); + continue; + } + + // Check for localizable properties + foreach (var prop in localizableProperties) + { + var isMissing = prop switch + { + "display_name" => string.IsNullOrWhiteSpace(localeResource.DisplayName), + "description" => string.IsNullOrWhiteSpace(localeResource.Description), + "long_description" => string.IsNullOrWhiteSpace( + localeResource.LongDescription + ), + "author.name" => localeResource.Author == null + || string.IsNullOrWhiteSpace(localeResource.Author.Name), + "keywords" => localeResource.Keywords == null + || localeResource.Keywords.Count == 0, + _ => false, + }; + + if (isMissing) + { + errors.Add($"Missing localization for '{prop}' in {locale} ({filePath})"); + } + } + + // Check tool descriptions + if (toolsWithDescriptions.Count > 0) + { + var localizedTools = + localeResource.Tools ?? new List(); + foreach (var tool in toolsWithDescriptions) + { + var found = localizedTools.Any(t => + t.Name == tool.Name && !string.IsNullOrWhiteSpace(t.Description) + ); + + if (!found) + { + errors.Add( + $"Missing localized description for tool '{tool.Name}' in {locale} ({filePath})" + ); + } + } + } + + // Check prompt descriptions + if (promptsWithDescriptions.Count > 0) + { + var localizedPrompts = + localeResource.Prompts ?? new List(); + foreach (var prompt in promptsWithDescriptions) + { + verboseLog?.Invoke( + $"Ensuring prompt '{prompt.Name}' has localized content in {locale}" + ); + var found = localizedPrompts.Any(p => + p.Name == prompt.Name && !string.IsNullOrWhiteSpace(p.Description) + ); + + if (!found) + { + errors.Add( + $"Missing localized description for prompt '{prompt.Name}' in {locale} ({filePath})" + ); + } + } + } + } + catch (Exception ex) + { + errors.Add($"Error reading locale file {filePath}: {ex.Message}"); + } + } + + return errors; + } + + private static List<(string locale, string filePath)> FindLocaleFiles( + string resourcePattern, + string baseDir, + string defaultLocale + ) + { + var localeFiles = new List<(string, string)>(); + + // Extract the directory and file pattern + var patternIndex = resourcePattern.IndexOf("${locale}", StringComparison.OrdinalIgnoreCase); + if (patternIndex < 0) + return localeFiles; + + var beforePlaceholder = resourcePattern.Substring(0, patternIndex); + var afterPlaceholder = resourcePattern.Substring(patternIndex + "${locale}".Length); + + var lastSlash = beforePlaceholder.LastIndexOfAny(new[] { '/', '\\' }); + string dirPath, + filePrefix; + + if (lastSlash >= 0) + { + dirPath = beforePlaceholder.Substring(0, lastSlash); + filePrefix = beforePlaceholder.Substring(lastSlash + 1); + } + else + { + dirPath = ""; + filePrefix = beforePlaceholder; + } + + var fullDirPath = string.IsNullOrEmpty(dirPath) + ? baseDir + : Path.Combine(baseDir, dirPath.Replace('/', Path.DirectorySeparatorChar)); + + if (!Directory.Exists(fullDirPath)) + return localeFiles; + + // Find all files matching the pattern + var searchPattern = filePrefix + "*" + afterPlaceholder; + var files = Directory.GetFiles(fullDirPath, searchPattern, SearchOption.TopDirectoryOnly); + + foreach (var file in files) + { + var fileName = Path.GetFileName(file); + + // Extract locale from filename + if (fileName.StartsWith(filePrefix) && fileName.EndsWith(afterPlaceholder)) + { + var localeStart = filePrefix.Length; + var localeEnd = fileName.Length - afterPlaceholder.Length; + if (localeEnd > localeStart) + { + var locale = fileName.Substring(localeStart, localeEnd - localeStart); + localeFiles.Add((locale, file)); + } + } + } + + return localeFiles; + } + + internal static async Task DiscoverCapabilitiesAsync( + string dir, + McpbManifest manifest, + Action? logInfo, + Action? logWarning, + IReadOnlyDictionary>? userConfigOverrides = null + ) + { + var overrideTools = TryParseToolOverride("MCPB_TOOL_DISCOVERY_JSON"); + var overridePrompts = TryParsePromptOverride("MCPB_PROMPT_DISCOVERY_JSON"); + var overrideInitialize = TryParseInitializeOverride("MCPB_INITIALIZE_DISCOVERY_JSON"); + var overrideToolsList = TryParseToolsListOverride("MCPB_TOOLS_LIST_DISCOVERY_JSON"); + if ( + overrideTools != null + || overridePrompts != null + || overrideInitialize != null + || overrideToolsList != null + ) + { + return new CapabilityDiscoveryResult( + overrideTools ?? new List(), + overridePrompts ?? new List(), + overrideInitialize, + overrideToolsList, + 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(); + var providedUserConfig = userConfigOverrides ?? EmptyUserConfigOverrides; + EnsureRequiredUserConfigProvided(manifest, command, rawArgs, providedUserConfig); + + var originalCommand = command; + command = ExpandToken(command, dir, providedUserConfig); + var args = new List(); + foreach (var rawArg in rawArgs) + { + foreach (var expanded in ExpandArgumentValues(rawArg, dir, providedUserConfig)) + { + if (!string.IsNullOrWhiteSpace(expanded)) + { + args.Add(expanded); + } + } + } + command = NormalizePathForPlatform(command); + for (int i = 0; i < args.Count; i++) + args[i] = NormalizePathForPlatform(args[i]); + + if (IsCommandPathLike(originalCommand)) + { + var resolved = command; + if (!Path.IsPathRooted(resolved)) + { + resolved = Path.Combine(dir, resolved); + } + + if (File.Exists(resolved)) + { + command = resolved; + } + else if (TryResolveWindowsAppExecutionAlias(originalCommand, out var aliasPath)) + { + logInfo?.Invoke( + $"Resolved server command '{originalCommand}' via Windows app execution alias: {aliasPath}" + ); + command = aliasPath; + } + else + { + throw new InvalidOperationException( + $"Unable to locate server.mcp_config.command executable: {originalCommand}" + ); + } + } + + Dictionary? env = null; + if (cfg.Env != null && cfg.Env.Count > 0) + { + env = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in cfg.Env) + { + var expanded = ExpandToken(kv.Value, dir, providedUserConfig); + env[kv.Key] = NormalizePathForPlatform(expanded); + } + } + + var toolInfos = new List(); + var promptInfos = new List(); + McpbInitializeResult? initializeResponse = null; + McpbToolsListResult? toolsListResponse = null; + var clientCreated = false; + string? reportedServerName = null; + string? reportedServerVersion = null; + bool supportsToolsList = true; + bool supportsPromptsList = true; + try + { + using var cts = new CancellationTokenSource(DiscoveryTimeout); + 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)}" + ); + ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken token) + { + if (notification.Params is null) + { + return ValueTask.CompletedTask; + } + + try + { + var logParams = + notification.Params.Deserialize(); + if (logParams == null) + { + return ValueTask.CompletedTask; + } + + string? message = null; + if (logParams.Data is JsonElement dataElement) + { + if (dataElement.ValueKind == JsonValueKind.String) + { + message = dataElement.GetString(); + } + else if ( + dataElement.ValueKind != JsonValueKind.Null + && dataElement.ValueKind != JsonValueKind.Undefined + ) + { + message = dataElement.ToString(); + } + } + + var loggerName = string.IsNullOrWhiteSpace(logParams.Logger) + ? "server" + : logParams.Logger; + var text = string.IsNullOrWhiteSpace(message) + ? "(no details provided)" + : message!; + var formatted = $"[{loggerName}] {text}"; + if (logParams.Level >= LoggingLevel.Error) + { + logWarning?.Invoke($"MCP server error: {formatted}"); + } + else if (logParams.Level >= LoggingLevel.Warning) + { + logWarning?.Invoke($"MCP server warning: {formatted}"); + } + else + { + logInfo?.Invoke($"MCP server log ({logParams.Level}): {formatted}"); + } + } + catch (Exception ex) + { + logWarning?.Invoke( + $"Failed to process MCP server log notification: {ex.Message}" + ); + } + + return ValueTask.CompletedTask; + } + + var clientOptions = new McpClientOptions + { + InitializationTimeout = DiscoveryInitializationTimeout, + Handlers = new McpClientHandlers + { + NotificationHandlers = new[] + { + new KeyValuePair< + string, + Func + >(NotificationMethods.LoggingMessageNotification, HandleServerLog), + }, + }, + }; + + await using var client = await McpClient.CreateAsync( + transport, + clientOptions, + cancellationToken: cts.Token + ); + reportedServerName = client.ServerInfo?.Name; + reportedServerVersion = client.ServerInfo?.Version; + clientCreated = true; + + // Capture initialize response using McpClient properties + // Filter out null properties to match JsonIgnoreCondition.WhenWritingNull behavior + try + { + // Serialize and filter capabilities + object? capabilities = null; + JsonElement capabilitiesElement = default; + bool hasCapabilitiesElement = false; + if (client.ServerCapabilities != null) + { + var capJson = JsonSerializer.Serialize(client.ServerCapabilities); + var capElement = JsonSerializer.Deserialize(capJson); + capabilitiesElement = capElement; + hasCapabilitiesElement = true; + capabilities = FilterNullProperties(capElement); + } + + if (hasCapabilitiesElement) + { + supportsToolsList = SupportsCapability(capabilitiesElement, "tools"); + supportsPromptsList = SupportsCapability(capabilitiesElement, "prompts"); + } + + // 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}"); + } + + try + { + await client.PingAsync(cts.Token); + } + catch (OperationCanceledException) + { + logWarning?.Invoke( + "MCP server ping timed out during discovery; aborting capability checks." + ); + return new CapabilityDiscoveryResult( + DeduplicateTools(toolInfos), + DeduplicatePrompts(promptInfos), + initializeResponse, + toolsListResponse, + reportedServerName, + reportedServerVersion + ); + } + catch (Exception ex) + { + if (ex is McpException) + { + LogMcpFailure("ping", ex, logWarning); + } + else + { + logWarning?.Invoke($"MCP server ping failed during discovery: {ex.Message}"); + } + + return new CapabilityDiscoveryResult( + DeduplicateTools(toolInfos), + DeduplicatePrompts(promptInfos), + initializeResponse, + toolsListResponse, + reportedServerName, + reportedServerVersion + ); + } + + IList? tools = null; + if (supportsToolsList) + { + try + { + tools = await client.ListToolsAsync(null, cts.Token); + } + catch (OperationCanceledException) + { + logWarning?.Invoke("tools/list request timed out during discovery."); + } + catch (Exception ex) + { + if (ex is McpException) + { + LogMcpFailure("tools/list", ex, logWarning); + } + else + { + logWarning?.Invoke($"tools/list request failed: {ex.Message}"); + } + } + } + else + { + logInfo?.Invoke( + "Server capabilities did not include 'tools'; skipping tools/list request." + ); + } + + if (tools != null) + { + // 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); + } + } + IList? prompts = null; + if (supportsPromptsList) + { + try + { + prompts = await client.ListPromptsAsync(cts.Token); + } + catch (OperationCanceledException) + { + logWarning?.Invoke("prompt list request timed out during discovery."); + } + catch (Exception ex) + { + if (ex is McpException) + { + LogMcpFailure("prompts/list", ex, logWarning); + } + else + { + logWarning?.Invoke($"Prompt discovery skipped: {ex.Message}"); + } + } + } + else + { + logInfo?.Invoke( + "Server capabilities did not include 'prompts'; skipping prompts/list request." + ); + } + + if (prompts != null) + { + 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 (OperationCanceledException) + { + logWarning?.Invoke( + $"Prompt '{prompt.Name}' content fetch timed out during discovery." + ); + manifestPrompt.Text = string.Empty; + } + catch (Exception ex) + { + if (ex is McpException) + { + LogMcpFailure($"prompt content fetch '{prompt.Name}'", ex, logWarning); + } + else + { + logWarning?.Invoke( + $"Prompt '{prompt.Name}' content fetch failed: {ex.Message}" + ); + } + manifestPrompt.Text = string.Empty; + } + promptInfos.Add(manifestPrompt); + } + } + } + catch (OperationCanceledException) when (clientCreated) + { + logWarning?.Invoke("MCP client discovery timed out."); + } + catch (Exception ex) when (clientCreated) + { + if (ex is McpException) + { + LogMcpFailure("discovery", ex, logWarning); + } + else + { + logWarning?.Invoke($"MCP client discovery failed: {ex.Message}"); + } + } + + return new CapabilityDiscoveryResult( + DeduplicateTools(toolInfos), + DeduplicatePrompts(promptInfos), + initializeResponse, + toolsListResponse, + reportedServerName, + reportedServerVersion + ); + } + + private static void LogMcpFailure(string operation, Exception ex, Action? logWarning) + { + var details = FormatMcpError(ex); + logWarning?.Invoke($"MCP server error during {operation}: {details}"); + } + + private static bool SupportsCapability(JsonElement capabilitiesElement, string capabilityName) + { + if (capabilitiesElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + foreach (var property in capabilitiesElement.EnumerateObject()) + { + if (string.Equals(property.Name, capabilityName, StringComparison.OrdinalIgnoreCase)) + { + var kind = property.Value.ValueKind; + return kind != JsonValueKind.Null && kind != JsonValueKind.Undefined; + } + } + + return false; + } + + private static string FormatMcpError(Exception ex) + { + if (ex is McpException) + { + var message = ex.Message; + var type = ex.GetType(); + if ( + string.Equals( + type.FullName, + "ModelContextProtocol.McpProtocolException", + StringComparison.Ordinal + ) + ) + { + var errorCodeProperty = type.GetProperty("ErrorCode"); + if (errorCodeProperty?.GetValue(ex) is Enum errorCode) + { + message += $" (code {Convert.ToInt32(errorCode)} {errorCode})"; + } + } + + return message; + } + + return ex.Message; + } + + 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, + IReadOnlyDictionary>? userConfigOverrides = null + ) + { + 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(); + var overrides = userConfigOverrides ?? EmptyUserConfigOverrides; + 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)) + { + var key = token.Substring("user_config.".Length); + if ( + overrides.TryGetValue(key, out var provided) + && provided != null + && provided.Count > 0 + ) + { + return provided[0] ?? string.Empty; + } + return string.Empty; + } + return m.Value; + } + ); + } + + private static IEnumerable ExpandArgumentValues( + string value, + string dir, + IReadOnlyDictionary> userConfigOverrides + ) + { + if (string.IsNullOrWhiteSpace(value)) + yield break; + + if ( + TryGetStandaloneUserConfigKey(value, out var key) + && userConfigOverrides.TryGetValue(key, out var values) + && values != null + && values.Count > 0 + ) + { + foreach (var userValue in values) + { + yield return userValue ?? string.Empty; + } + yield break; + } + + yield return ExpandToken(value, dir, userConfigOverrides); + } + + private static bool TryGetStandaloneUserConfigKey(string value, out string key) + { + key = string.Empty; + if (string.IsNullOrWhiteSpace(value)) + return false; + + var trimmed = value.Trim(); + if (!string.Equals(trimmed, value, StringComparison.Ordinal)) + return false; + + const string prefix = "${user_config."; + const string suffix = "}"; + if ( + !trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + || !trimmed.EndsWith(suffix, StringComparison.Ordinal) + ) + return false; + + var innerLength = trimmed.Length - prefix.Length - suffix.Length; + if (innerLength <= 0) + return false; + + key = trimmed.Substring(prefix.Length, innerLength); + return true; + } + + private static void EnsureRequiredUserConfigProvided( + McpbManifest manifest, + string command, + IEnumerable args, + IReadOnlyDictionary> providedUserConfig + ) + { + if (manifest.UserConfig == null || manifest.UserConfig.Count == 0) + return; + + var referenced = new HashSet(StringComparer.Ordinal); + AddUserConfigReferences(command, referenced); + foreach (var arg in args) + { + AddUserConfigReferences(arg, referenced); + } + + if (referenced.Count == 0) + return; + + var missing = new List(); + foreach (var key in referenced) + { + if (manifest.UserConfig.TryGetValue(key, out var option) && option?.Required == true) + { + if ( + !providedUserConfig.TryGetValue(key, out var values) + || values == null + || values.Count == 0 + || values.Any(string.IsNullOrWhiteSpace) + ) + { + missing.Add(key); + } + } + } + + if (missing.Count == 0) + return; + + var suffix = missing.Count > 1 ? "s" : string.Empty; + var keys = string.Join(", ", missing.Select(k => $"'{k}'")); + var suggestion = string.Join(" ", missing.Select(k => $"--user_config {k}=")); + throw new UserConfigRequiredException( + $"Discovery requires user_config value{suffix} for {keys}. Provide value{suffix} via {suggestion}." + ); + } + + private static void AddUserConfigReferences(string? value, HashSet collector) + { + if (string.IsNullOrWhiteSpace(value)) + return; + foreach (Match match in UserConfigTokenRegex.Matches(value)) + { + var key = match.Groups[1].Value?.Trim(); + if (!string.IsNullOrEmpty(key)) + { + collector.Add(key); + } + } + } + + 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 McpbInitializeResult? TryParseInitializeOverride(string envVar) + { + var json = Environment.GetEnvironmentVariable(envVar); + if (string.IsNullOrWhiteSpace(json)) + return null; + try + { + return JsonSerializer.Deserialize(json, McpbJsonContext.Default.McpbInitializeResult); + } + catch + { + return null; + } + } + + private static McpbToolsListResult? TryParseToolsListOverride(string envVar) + { + var json = Environment.GetEnvironmentVariable(envVar); + if (string.IsNullOrWhiteSpace(json)) + return null; + try + { + return JsonSerializer.Deserialize(json, McpbJsonContext.Default.McpbToolsListResult); + } + 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(); + } + + internal static CapabilityComparisonResult CompareTools( + IEnumerable? manifestTools, + IEnumerable discoveredTools + ) + { + var summaryTerms = new List(); + var messages = new List(); + + var manifestNames = + manifestTools + ?.Where(t => !string.IsNullOrWhiteSpace(t.Name)) + .Select(t => t.Name) + .ToList() ?? new List(); + manifestNames.Sort(StringComparer.Ordinal); + + var discoveredNames = discoveredTools + .Where(t => !string.IsNullOrWhiteSpace(t.Name)) + .Select(t => t.Name) + .ToList(); + discoveredNames.Sort(StringComparer.Ordinal); + + bool namesDiffer = !manifestNames.SequenceEqual(discoveredNames, StringComparer.Ordinal); + if (namesDiffer) + { + summaryTerms.Add("tool names"); + var sb = new StringBuilder(); + sb.AppendLine("Tool list mismatch:"); + sb.AppendLine(" Manifest: [" + string.Join(", ", manifestNames) + "]"); + sb.Append(" Discovered: [" + string.Join(", ", discoveredNames) + "]"); + messages.Add(sb.ToString()); + } + + var metadataDiffs = GetToolMetadataDifferences(manifestTools, discoveredTools); + bool metadataDiffer = metadataDiffs.Count > 0; + if (metadataDiffer) + { + summaryTerms.Add("tool metadata"); + var sb = new StringBuilder(); + sb.AppendLine("Tool metadata mismatch:"); + foreach (var diff in metadataDiffs) + { + sb.AppendLine(" " + diff); + } + messages.Add(sb.ToString().TrimEnd()); + } + + return new CapabilityComparisonResult(namesDiffer, metadataDiffer, summaryTerms, messages); + } + + internal static CapabilityComparisonResult ComparePrompts( + IEnumerable? manifestPrompts, + IEnumerable discoveredPrompts + ) + { + var summaryTerms = new List(); + var messages = new List(); + + var manifestNames = + manifestPrompts + ?.Where(p => !string.IsNullOrWhiteSpace(p.Name)) + .Select(p => p.Name) + .ToList() ?? new List(); + manifestNames.Sort(StringComparer.Ordinal); + + var discoveredNames = discoveredPrompts + .Where(p => !string.IsNullOrWhiteSpace(p.Name)) + .Select(p => p.Name) + .ToList(); + discoveredNames.Sort(StringComparer.Ordinal); + + bool namesDiffer = !manifestNames.SequenceEqual(discoveredNames, StringComparer.Ordinal); + if (namesDiffer) + { + summaryTerms.Add("prompt names"); + var sb = new StringBuilder(); + sb.AppendLine("Prompt list mismatch:"); + sb.AppendLine(" Manifest: [" + string.Join(", ", manifestNames) + "]"); + sb.Append(" Discovered: [" + string.Join(", ", discoveredNames) + "]"); + messages.Add(sb.ToString()); + } + + var metadataDiffs = GetPromptMetadataDifferences(manifestPrompts, discoveredPrompts); + bool metadataDiffer = metadataDiffs.Count > 0; + if (metadataDiffer) + { + summaryTerms.Add("prompt metadata"); + var sb = new StringBuilder(); + sb.AppendLine("Prompt metadata mismatch:"); + foreach (var diff in metadataDiffs) + { + sb.AppendLine(" " + diff); + } + messages.Add(sb.ToString().TrimEnd()); + } + + return new CapabilityComparisonResult(namesDiffer, metadataDiffer, summaryTerms, messages); + } + + internal static StaticResponseComparisonResult CompareStaticResponses( + McpbManifest manifest, + McpbInitializeResult? initializeResponse, + McpbToolsListResult? toolsListResponse + ) + { + var summaryTerms = new List(); + var messages = new List(); + bool initializeDiffers = false; + bool toolsListDiffers = false; + + var windowsMeta = GetWindowsMeta(manifest); + var staticResponses = windowsMeta.StaticResponses; + + if (initializeResponse != null) + { + var expected = BuildInitializeStaticResponse(initializeResponse); + if (staticResponses?.Initialize == null) + { + initializeDiffers = true; + summaryTerms.Add("static_responses.initialize"); + messages.Add( + "Missing _meta.static_responses.initialize; discovery returned an initialize payload." + ); + } + else if (!AreJsonEquivalent(staticResponses.Initialize, expected)) + { + initializeDiffers = true; + summaryTerms.Add("static_responses.initialize"); + messages.Add( + "_meta.static_responses.initialize differs from discovered initialize payload." + ); + } + } + + if (toolsListResponse != null) + { + if (staticResponses?.ToolsList == null) + { + toolsListDiffers = true; + summaryTerms.Add("static_responses.tools/list"); + messages.Add( + "Missing _meta.static_responses.\"tools/list\"; discovery returned a tools/list payload." + ); + } + else if (!AreJsonEquivalent(staticResponses.ToolsList, toolsListResponse)) + { + toolsListDiffers = true; + summaryTerms.Add("static_responses.tools/list"); + messages.Add( + "_meta.static_responses.\"tools/list\" differs from discovered tools/list payload." + ); + } + } + + return new StaticResponseComparisonResult( + initializeDiffers, + toolsListDiffers, + summaryTerms, + messages + ); + } + + internal static bool ApplyWindowsMetaStaticResponses( + McpbManifest manifest, + McpbInitializeResult? initializeResponse, + McpbToolsListResult? toolsListResponse + ) + { + if (initializeResponse == null && toolsListResponse == null) + { + return false; + } + + var windowsMeta = GetWindowsMeta(manifest); + var staticResponses = windowsMeta.StaticResponses ?? new McpbStaticResponses(); + bool changed = false; + + if (initializeResponse != null) + { + var initializePayload = BuildInitializeStaticResponse(initializeResponse); + if (!AreJsonEquivalent(staticResponses.Initialize, initializePayload)) + { + staticResponses.Initialize = initializePayload; + changed = true; + } + } + + if (toolsListResponse != null) + { + if (!AreJsonEquivalent(staticResponses.ToolsList, toolsListResponse)) + { + staticResponses.ToolsList = toolsListResponse; + changed = true; + } + } + + if (!changed) + { + return false; + } + + windowsMeta.StaticResponses = staticResponses; + SetWindowsMeta(manifest, windowsMeta); + return true; + } + + 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 Dictionary BuildInitializeStaticResponse( + McpbInitializeResult response + ) + { + var result = new Dictionary(); + if (!string.IsNullOrWhiteSpace(response.ProtocolVersion)) + { + result["protocolVersion"] = response.ProtocolVersion!; + } + if (response.Capabilities != null) + { + result["capabilities"] = response.Capabilities; + } + if (response.ServerInfo != null) + { + result["serverInfo"] = response.ServerInfo; + } + if (!string.IsNullOrWhiteSpace(response.Instructions)) + { + result["instructions"] = response.Instructions!; + } + return result; + } + + 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)"; + } + + private static McpbWindowsMeta GetWindowsMeta(McpbManifest manifest) + { + if (manifest.Meta == null) + { + return new McpbWindowsMeta(); + } + + if (!manifest.Meta.TryGetValue("com.microsoft.windows", out var windowsMetaDict)) + { + return new McpbWindowsMeta(); + } + + try + { + var json = JsonSerializer.Serialize(windowsMetaDict, McpbJsonContext.WriteOptions); + return JsonSerializer.Deserialize(json, McpbJsonContext.Default.McpbWindowsMeta) + as McpbWindowsMeta + ?? new McpbWindowsMeta(); + } + catch + { + return new McpbWindowsMeta(); + } + } + + private static void SetWindowsMeta(McpbManifest manifest, McpbWindowsMeta windowsMeta) + { + manifest.Meta ??= new Dictionary>( + StringComparer.Ordinal + ); + + var json = JsonSerializer.Serialize(windowsMeta, McpbJsonContext.WriteOptions); + var dict = + JsonSerializer.Deserialize(json, McpbJsonContext.Default.DictionaryStringObject) + as Dictionary + ?? new Dictionary(); + + manifest.Meta["com.microsoft.windows"] = dict; + } + + private static bool AreJsonEquivalent(object? a, object? b) + { + if (ReferenceEquals(a, b)) + return true; + if (a == null || b == null) + return false; + + try + { + var jsonA = JsonSerializer.Serialize(a, McpbJsonContext.WriteOptions); + var jsonB = JsonSerializer.Serialize(b, McpbJsonContext.WriteOptions); + return string.Equals(jsonA, jsonB, StringComparison.Ordinal); + } + catch + { + return false; + } + } +} diff --git a/dotnet/mcpb/Commands/PackCommand.cs b/dotnet/mcpb/Commands/PackCommand.cs new file mode 100644 index 0000000..16b7231 --- /dev/null +++ b/dotnet/mcpb/Commands/PackCommand.cs @@ -0,0 +1,532 @@ +using System; +using System.CommandLine; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Mcpb.Core; +using Mcpb.Json; + +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 userConfigOpt = new Option( + name: "--user_config", + description: "Provide user_config overrides as name=value. Repeat to set more keys or add multiple values for a key." + ) + { + AllowMultipleArgumentsPerToken = true, + }; + userConfigOpt.AddAlias("--user-config"); + userConfigOpt.ArgumentHelpName = "name=value"; + userConfigOpt.SetDefaultValue(Array.Empty()); + var cmd = new Command("pack", "Pack a directory into an MCPB extension") + { + dirArg, + outputArg, + forceOpt, + updateOpt, + noDiscoverOpt, + userConfigOpt, + }; + cmd.SetHandler( + async ( + string? directory, + string? output, + bool force, + bool update, + bool noDiscover, + string[] userConfigRaw + ) => + { + if ( + !UserConfigOptionParser.TryParse( + userConfigRaw, + out var userConfigOverrides, + out var parseError + ) + ) + { + Console.Error.WriteLine($"ERROR: {parseError}"); + Environment.ExitCode = 1; + return; + } + 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}"), + userConfigOverrides + ); + discoveredTools = result.Tools; + discoveredPrompts = result.Prompts; + discoveredInitResponse = result.InitializeResponse; + discoveredToolsListResponse = result.ToolsListResponse; + } + catch (ManifestCommandHelpers.UserConfigRequiredException ex) + { + Console.Error.WriteLine($"ERROR: {ex.Message}"); + Environment.ExitCode = 1; + return; + } + 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}"); + } + } + + bool metaUpdated = false; + if (update) + { + metaUpdated = ManifestCommandHelpers.ApplyWindowsMetaStaticResponses( + manifest, + discoveredInitResponse, + discoveredToolsListResponse + ); + } + + 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." + ); + if (metaUpdated) + { + Console.WriteLine( + "Updated manifest.json _meta static_responses 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." + ); + } + } + else if (metaUpdated && update) + { + File.WriteAllText( + manifestPath, + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); + Console.WriteLine( + "Updated manifest.json _meta static_responses to match discovered results." + ); + } + + // 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, + userConfigOpt + ); + 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); +} 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/UserConfigOptionParser.cs b/dotnet/mcpb/Commands/UserConfigOptionParser.cs new file mode 100644 index 0000000..38ef080 --- /dev/null +++ b/dotnet/mcpb/Commands/UserConfigOptionParser.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Mcpb.Commands; + +internal static class UserConfigOptionParser +{ + public static bool TryParse( + IEnumerable? values, + out Dictionary> result, + out string? error + ) + { + result = new Dictionary>(StringComparer.Ordinal); + var temp = new Dictionary>(StringComparer.Ordinal); + error = null; + if (values == null) + { + return true; + } + + foreach (var raw in values) + { + if (string.IsNullOrWhiteSpace(raw)) + { + error = "--user_config values cannot be empty"; + return false; + } + + var separatorIndex = raw.IndexOf('='); + if (separatorIndex <= 0) + { + error = $"Invalid --user_config value '{raw}'. Use name=value."; + return false; + } + + var key = raw.Substring(0, separatorIndex); + var value = raw.Substring(separatorIndex + 1); + if (string.IsNullOrWhiteSpace(key)) + { + error = $"Invalid --user_config value '{raw}'. Key cannot be empty."; + return false; + } + + if (!temp.TryGetValue(key, out var valueList)) + { + valueList = new List(); + temp[key] = valueList; + } + + valueList.Add(value); + } + + result = temp.ToDictionary( + kvp => kvp.Key, + kvp => (IReadOnlyList)kvp.Value, + StringComparer.Ordinal + ); + return true; + } +} diff --git a/dotnet/mcpb/Commands/ValidateCommand.cs b/dotnet/mcpb/Commands/ValidateCommand.cs new file mode 100644 index 0000000..0aafba1 --- /dev/null +++ b/dotnet/mcpb/Commands/ValidateCommand.cs @@ -0,0 +1,784 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Linq; +using System.Text; +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 discoverOpt = new Option( + "--discover", + description: "Validate that discovered tools/prompts match manifest without updating" + ); + var verboseOpt = new Option( + "--verbose", + description: "Print detailed validation steps" + ); + var userConfigOpt = new Option( + name: "--user_config", + description: "Provide user_config overrides as name=value. Repeat to set more keys or add multiple values for a key." + ) + { + AllowMultipleArgumentsPerToken = true, + }; + userConfigOpt.AddAlias("--user-config"); + userConfigOpt.ArgumentHelpName = "name=value"; + userConfigOpt.SetDefaultValue(Array.Empty()); + var cmd = new Command("validate", "Validate an MCPB manifest file") + { + manifestArg, + dirnameOpt, + updateOpt, + discoverOpt, + verboseOpt, + userConfigOpt, + }; + cmd.SetHandler( + async ( + string? path, + string? dirname, + bool update, + bool discover, + bool verbose, + string[] userConfigRaw + ) => + { + if ( + !UserConfigOptionParser.TryParse( + userConfigRaw, + out var userConfigOverrides, + out var parseError + ) + ) + { + Console.Error.WriteLine($"ERROR: {parseError}"); + Environment.ExitCode = 1; + return; + } + if (update && discover) + { + Console.Error.WriteLine( + "ERROR: --discover and --update cannot be used together." + ); + 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); + void LogVerbose(string message) + { + if (verbose) + Console.WriteLine($"VERBOSE: {message}"); + } + var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath)); + if (discover && string.IsNullOrWhiteSpace(dirname)) + { + dirname = manifestDirectory; + if (!string.IsNullOrWhiteSpace(dirname)) + { + LogVerbose($"Using manifest directory {dirname} for discovery"); + } + } + if (update && string.IsNullOrWhiteSpace(dirname)) + { + Console.Error.WriteLine( + "ERROR: --update requires --dirname to locate manifest assets." + ); + Environment.ExitCode = 1; + return; + } + if (Environment.GetEnvironmentVariable("MCPB_DEBUG_VALIDATE") == "1") + { + Console.WriteLine( + $"DEBUG: Read manifest {manifestPath} length={json.Length}" + ); + } + LogVerbose($"Validating manifest JSON at {manifestPath}"); + + 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(); + var discoveryViolations = new List(); + var mismatchSummary = new List(); + bool discoveryMismatchOccurred = false; + bool assetPathsNormalized = false; + bool manifestNameUpdated = false; + bool manifestVersionUpdated = false; + + // Parse JSON to get root properties for localization validation + HashSet? rootProps = null; + using (var doc = JsonDocument.Parse(json)) + { + rootProps = new HashSet(StringComparer.OrdinalIgnoreCase); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + foreach (var p in doc.RootElement.EnumerateObject()) + rootProps.Add(p.Name); + } + + if (!string.IsNullOrWhiteSpace(dirname)) + { + var baseDir = Path.GetFullPath(dirname); + LogVerbose($"Checking referenced assets using directory {baseDir}"); + if (!Directory.Exists(baseDir)) + { + Console.Error.WriteLine($"ERROR: Directory not found: {baseDir}"); + PrintWarnings(currentWarnings, toError: true); + Environment.ExitCode = 1; + return; + } + + if (update) + { + assetPathsNormalized = + NormalizeManifestAssetPaths(manifest) || assetPathsNormalized; + } + + var fileErrors = ManifestCommandHelpers.ValidateReferencedFiles( + manifest, + baseDir, + LogVerbose + ); + foreach (var err in fileErrors) + { + var message = err; + if ( + err.Contains( + "path must use '/' as directory separator", + StringComparison.Ordinal + ) + ) + { + message += + " Run validate --update to normalize manifest asset paths."; + } + additionalErrors.Add($"ERROR: {message}"); + } + + var localizationErrors = + ManifestCommandHelpers.ValidateLocalizationCompleteness( + manifest, + baseDir, + rootProps, + LogVerbose + ); + foreach (var err in localizationErrors) + { + additionalErrors.Add($"ERROR: {err}"); + } + + if (discover) + { + LogVerbose("Running discovery to compare manifest capabilities"); + } + void RecordDiscoveryViolation(string message) + { + if (string.IsNullOrWhiteSpace(message)) + return; + discoveryViolations.Add(message); + if (update) + { + Console.WriteLine(message); + } + else + { + LogVerbose(message); + } + } + ManifestCommandHelpers.CapabilityDiscoveryResult? discovery = null; + try + { + discovery = await ManifestCommandHelpers.DiscoverCapabilitiesAsync( + baseDir, + manifest, + message => + { + if (verbose) + Console.WriteLine($"VERBOSE: {message}"); + else + Console.WriteLine(message); + }, + warning => + { + if (verbose) + Console.Error.WriteLine($"VERBOSE WARNING: {warning}"); + else + Console.Error.WriteLine($"WARNING: {warning}"); + }, + userConfigOverrides + ); + } + catch (ManifestCommandHelpers.UserConfigRequiredException ex) + { + additionalErrors.Add($"ERROR: {ex.Message}"); + } + catch (InvalidOperationException ex) + { + additionalErrors.Add($"ERROR: {ex.Message}"); + } + catch (Exception ex) + { + additionalErrors.Add($"ERROR: MCP discovery failed: {ex.Message}"); + } + + if (discovery != null) + { + var discoveredTools = discovery.Tools; + var discoveredPrompts = discovery.Prompts; + var discoveredInitResponse = discovery.InitializeResponse; + var discoveredToolsListResponse = discovery.ToolsListResponse; + var reportedServerName = discovery.ReportedServerName; + var reportedServerVersion = discovery.ReportedServerVersion; + + if (!string.IsNullOrWhiteSpace(reportedServerName)) + { + var originalManifestName = manifest.Name; + if ( + !string.Equals( + originalManifestName, + reportedServerName, + StringComparison.Ordinal + ) + ) + { + if (update) + { + manifest.Name = reportedServerName; + manifestNameUpdated = true; + } + else + { + discoveryMismatchOccurred = true; + mismatchSummary.Add("server name"); + RecordDiscoveryViolation( + $"Server reported name '{reportedServerName}', but manifest name is '{originalManifestName}'. Run validate --update to sync the manifest name." + ); + } + } + } + + if (!string.IsNullOrWhiteSpace(reportedServerVersion)) + { + var originalVersion = manifest.Version; + if ( + !string.Equals( + originalVersion, + reportedServerVersion, + StringComparison.Ordinal + ) + ) + { + if (update) + { + manifest.Version = reportedServerVersion; + manifestVersionUpdated = true; + } + else + { + discoveryMismatchOccurred = true; + mismatchSummary.Add("server version"); + RecordDiscoveryViolation( + $"Server reported version '{reportedServerVersion}', but manifest version is '{originalVersion}'. Run validate --update to sync the version." + ); + } + } + } + + var sortedDiscoveredTools = discoveredTools + .Where(t => !string.IsNullOrWhiteSpace(t.Name)) + .Select(t => t.Name) + .ToList(); + sortedDiscoveredTools.Sort(StringComparer.Ordinal); + + var sortedDiscoveredPrompts = discoveredPrompts + .Where(p => !string.IsNullOrWhiteSpace(p.Name)) + .Select(p => p.Name) + .ToList(); + sortedDiscoveredPrompts.Sort(StringComparer.Ordinal); + + void HandleCapabilityDifferences( + ManifestCommandHelpers.CapabilityComparisonResult comparison + ) + { + if (!comparison.HasDifferences) + return; + discoveryMismatchOccurred = true; + foreach (var term in comparison.SummaryTerms) + { + mismatchSummary.Add(term); + } + foreach (var message in comparison.Messages) + { + RecordDiscoveryViolation(message); + } + } + + var toolComparison = ManifestCommandHelpers.CompareTools( + manifest.Tools, + discoveredTools + ); + var promptComparison = ManifestCommandHelpers.ComparePrompts( + manifest.Prompts, + discoveredPrompts + ); + var staticResponseComparison = + ManifestCommandHelpers.CompareStaticResponses( + manifest, + discoveredInitResponse, + discoveredToolsListResponse + ); + + HandleCapabilityDifferences(toolComparison); + HandleCapabilityDifferences(promptComparison); + + if (staticResponseComparison.HasDifferences) + { + discoveryMismatchOccurred = true; + foreach (var term in staticResponseComparison.SummaryTerms) + { + mismatchSummary.Add(term); + } + foreach (var message in staticResponseComparison.Messages) + { + RecordDiscoveryViolation(message); + } + } + + var promptWarnings = ManifestCommandHelpers.GetPromptTextWarnings( + manifest.Prompts, + discoveredPrompts + ); + foreach (var warning in promptWarnings) + { + Console.Error.WriteLine($"WARNING: {warning}"); + } + + bool toolUpdatesApplied = false; + bool promptUpdatesApplied = false; + bool metaUpdated = false; + + if (update) + { + metaUpdated = + ManifestCommandHelpers.ApplyWindowsMetaStaticResponses( + manifest, + discoveredInitResponse, + discoveredToolsListResponse + ); + + if (toolComparison.NamesDiffer || toolComparison.MetadataDiffer) + { + manifest.Tools = discoveredTools + .Select(t => new McpbManifestTool + { + Name = t.Name, + Description = t.Description, + }) + .ToList(); + manifest.ToolsGenerated ??= false; + toolUpdatesApplied = true; + } + if (promptComparison.NamesDiffer || promptComparison.MetadataDiffer) + { + manifest.Prompts = ManifestCommandHelpers.MergePromptMetadata( + manifest.Prompts, + discoveredPrompts + ); + manifest.PromptsGenerated ??= false; + promptUpdatesApplied = true; + } + } + + if ( + update + && ( + toolUpdatesApplied + || promptUpdatesApplied + || metaUpdated + || manifestNameUpdated + || assetPathsNormalized + || manifestVersionUpdated + ) + ) + { + 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; + } + + if ( + !string.IsNullOrWhiteSpace(reportedServerVersion) + && !string.Equals( + updatedManifest.Version, + reportedServerVersion, + StringComparison.Ordinal + ) + ) + { + Console.Error.WriteLine( + "ERROR: Updated manifest version still differs from MCP server version (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; + } + + if (toolUpdatesApplied || promptUpdatesApplied) + { + Console.WriteLine( + "Updated manifest.json capabilities to match discovered results." + ); + } + if (metaUpdated) + { + Console.WriteLine( + "Updated manifest.json _meta static_responses to match discovered results." + ); + } + if (manifestNameUpdated) + { + Console.WriteLine( + "Updated manifest name to match MCP server name." + ); + } + if (manifestVersionUpdated) + { + Console.WriteLine( + "Updated manifest version to match MCP server version." + ); + } + if (assetPathsNormalized) + { + Console.WriteLine( + "Normalized manifest asset paths to use forward slashes." + ); + } + + manifest = updatedManifest; + currentWarnings = new List(updatedWarnings); + } + } + } + + if (discoveryMismatchOccurred && !update) + { + foreach (var violation in discoveryViolations) + { + additionalErrors.Add("ERROR: " + violation); + } + var summarySuffix = + mismatchSummary.Count > 0 + ? " (" + + string.Join( + ", ", + mismatchSummary.Distinct(StringComparer.Ordinal) + ) + + ")" + : string.Empty; + if (discover) + { + additionalErrors.Add( + "ERROR: Discovered capabilities differ from manifest" + + summarySuffix + + "." + ); + } + else + { + additionalErrors.Add( + "ERROR: Discovered capabilities differ from manifest" + + summarySuffix + + ". Use --discover to verify or 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, + discoverOpt, + verboseOpt, + userConfigOpt + ); + return cmd; + } + + private static bool NormalizeManifestAssetPaths(McpbManifest manifest) + { + bool changed = false; + + static bool LooksLikeAbsolutePath(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + if (value.Length >= 2 && char.IsLetter(value[0]) && value[1] == ':') + return true; + if ( + value.StartsWith("\\\\", StringComparison.Ordinal) + || value.StartsWith("//", StringComparison.Ordinal) + ) + return true; + return false; + } + + static bool NormalizeRelativePath(ref string value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + if (LooksLikeAbsolutePath(value)) + return false; + + var original = value; + var trimmed = value.TrimStart('/', '\\'); + if (!string.Equals(trimmed, value, StringComparison.Ordinal)) + { + value = trimmed; + } + + var replaced = value.Replace('\\', '/'); + if (!string.Equals(replaced, value, StringComparison.Ordinal)) + { + value = replaced; + } + + return !string.Equals(value, original, StringComparison.Ordinal); + } + + if (!string.IsNullOrWhiteSpace(manifest.Icon)) + { + var iconPath = manifest.Icon; + if (NormalizeRelativePath(ref iconPath)) + { + manifest.Icon = iconPath; + changed = true; + } + } + + if (!string.IsNullOrWhiteSpace(manifest.Server?.EntryPoint)) + { + var entryPoint = manifest.Server!.EntryPoint; + if (NormalizeRelativePath(ref entryPoint)) + { + manifest.Server.EntryPoint = entryPoint; + changed = true; + } + } + + if (manifest.Screenshots != null) + { + for (int i = 0; i < manifest.Screenshots.Count; i++) + { + var shot = manifest.Screenshots[i]; + if (NormalizeRelativePath(ref shot)) + { + manifest.Screenshots[i] = shot; + changed = true; + } + } + } + + if (manifest.Icons != null) + { + foreach (var icon in manifest.Icons) + { + if (icon == null || string.IsNullOrWhiteSpace(icon.Src)) + continue; + var src = icon.Src; + if (NormalizeRelativePath(ref src)) + { + icon.Src = src; + changed = true; + } + } + } + + if (!string.IsNullOrWhiteSpace(manifest.Localization?.Resources)) + { + var resources = manifest.Localization!.Resources!; + if (NormalizeRelativePath(ref resources)) + { + manifest.Localization.Resources = resources; + changed = true; + } + } + + return changed; + } +} 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..ded212e --- /dev/null +++ b/dotnet/mcpb/Core/ManifestModels.cs @@ -0,0 +1,226 @@ +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 McpbManifestLocalization +{ + [JsonPropertyName("resources")] public string? Resources { get; set; } + [JsonPropertyName("default_locale")] public string? DefaultLocale { get; set; } +} + +public class McpbManifestIcon +{ + [JsonPropertyName("src")] public string Src { get; set; } = string.Empty; + [JsonPropertyName("size")] public string Size { get; set; } = string.Empty; + [JsonPropertyName("theme")] public string? Theme { get; set; } +} + +public class McpbLocalizationResourceAuthor +{ + [JsonPropertyName("name")] public string? Name { get; set; } +} + +public class McpbLocalizationResourceTool +{ + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("description")] public string? Description { get; set; } +} + +public class McpbLocalizationResourcePrompt +{ + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("description")] public string? Description { get; set; } +} + +public class McpbLocalizationResource +{ + [JsonPropertyName("display_name")] public string? DisplayName { get; set; } + [JsonPropertyName("description")] public string? Description { get; set; } + [JsonPropertyName("long_description")] public string? LongDescription { get; set; } + [JsonPropertyName("author")] public McpbLocalizationResourceAuthor? Author { get; set; } + [JsonPropertyName("keywords")] public List? Keywords { get; set; } + [JsonPropertyName("tools")] public List? Tools { get; set; } + [JsonPropertyName("prompts")] public List? Prompts { get; set; } +} + +public class McpbInitializeResult +{ + [JsonPropertyName("protocolVersion")] public string? ProtocolVersion { get; set; } + [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("icons")] public List? Icons { get; set; } + [JsonPropertyName("screenshots")] public List? Screenshots { get; set; } + [JsonPropertyName("localization")] public McpbManifestLocalization? Localization { get; set; } + [JsonPropertyName("server")] public McpbManifestServer Server { get; set; } = new(); + [JsonPropertyName("tools")] public List? Tools { get; set; } + [JsonPropertyName("tools_generated")] public bool? ToolsGenerated { get; set; } + [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..2dc4e3b --- /dev/null +++ b/dotnet/mcpb/Core/ManifestValidator.cs @@ -0,0 +1,195 @@ +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")); + } + + if (m.Localization != null) + { + // Resources is optional; default is "mcpb-resources/${locale}.json" + var resources = m.Localization.Resources ?? "mcpb-resources/${locale}.json"; + if (!resources.Contains("${locale}", StringComparison.OrdinalIgnoreCase)) + issues.Add(new("localization.resources", "resources must include a \"${locale}\" placeholder")); + + // DefaultLocale is optional; default is "en-US" + var defaultLocale = m.Localization.DefaultLocale ?? "en-US"; + if (!Regex.IsMatch(defaultLocale, "^[A-Za-z0-9]{2,8}(?:-[A-Za-z0-9]{1,8})*$")) + issues.Add(new("localization.default_locale", "default_locale must be a valid BCP 47 locale identifier")); + } + + if (m.Icons != null) + { + for (int i = 0; i < m.Icons.Count; i++) + { + var icon = m.Icons[i]; + if (string.IsNullOrWhiteSpace(icon.Src)) + issues.Add(new($"icons[{i}].src", "src is required")); + if (string.IsNullOrWhiteSpace(icon.Size)) + issues.Add(new($"icons[{i}].size", "size is required")); + else if (!Regex.IsMatch(icon.Size, "^\\d+x\\d+$")) + issues.Add(new($"icons[{i}].size", "size must be in the format \"WIDTHxHEIGHT\" (e.g., \"16x16\")")); + if (icon.Theme != null && string.IsNullOrWhiteSpace(icon.Theme)) + issues.Add(new($"icons[{i}].theme", "theme cannot be empty when provided")); + } + } + + return issues; + } + + // 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..f3945e9 --- /dev/null +++ b/dotnet/mcpb/Json/JsonContext.cs @@ -0,0 +1,47 @@ +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(McpbManifestLocalization))] +[JsonSerializable(typeof(McpbManifestIcon))] +[JsonSerializable(typeof(McpbLocalizationResource))] +[JsonSerializable(typeof(McpbLocalizationResourceAuthor))] +[JsonSerializable(typeof(McpbLocalizationResourceTool))] +[JsonSerializable(typeof(McpbLocalizationResourcePrompt))] +[JsonSerializable(typeof(System.Text.Json.JsonElement))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(long))] + +[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..91cd4a8 --- /dev/null +++ b/dotnet/mcpb/mcpb.csproj @@ -0,0 +1,50 @@ + + + Exe + net8.0 + enable + enable + Mcpb + mcpb + true + mcpb + Mcpb.Cli + 0.3.5 + 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 + portable + true + en + + + + <_Parameter1>mcpb.Tests + + + + + + + + + + + + + + True + True + + + + + + + \ No newline at end of file