diff --git a/CLI.md b/CLI.md index cae6be4..02787f8 100644 --- a/CLI.md +++ b/CLI.md @@ -19,7 +19,7 @@ Options: Commands: init [directory] Create a new MCPB extension manifest - validate [manifest] Validate a MCPB manifest file + validate Validate a MCPB manifest file pack [output] Pack a directory into a MCPB extension sign [options] Sign a MCPB extension file verify Verify the signature of a MCPB extension file @@ -58,7 +58,7 @@ The command will prompt you for: After creating the manifest, it provides helpful next steps based on your server type. -### `mcpb validate [path]` +### `mcpb validate ` Validates a MCPB manifest file against the schema. You can provide either a direct path to a manifest.json file or a directory containing one. @@ -69,28 +69,8 @@ mcpb validate manifest.json # Validate manifest in directory mcpb validate ./my-extension mcpb validate . - -# Validate using --dirname without specifying manifest.json explicitly -mcpb validate --dirname ./my-extension ``` -#### Additional validation with `--dirname` - -Passing `--dirname ` performs deeper checks that require access to the extension's source files: - -- Verifies referenced assets exist relative to the directory (`icon`, each `screenshots` entry, `server.entry_point`, and path-like `server.mcp_config.command`). -- Launches the server (honoring `${__dirname}` tokens) and discovers tools & prompts using the same logic as `mcpb pack`. -- Compares discovered capability names against the manifest and fails if they differ. - -When `--dirname` is supplied without an explicit manifest argument, the CLI automatically resolves `/manifest.json`. Use `--update` alongside `--dirname` to rewrite the manifest in-place with the discovered tool/prompt lists (including `tools_generated` / `prompts_generated` flags). When rewriting, the CLI also copies over tool descriptions and prompt metadata (descriptions, declared arguments, and prompt text) returned by the server. Without `--update`, any mismatch causes the command to fail. - -The discovery step respects the same environment overrides as `mcpb pack`: - -- `MCPB_TOOL_DISCOVERY_JSON` -- `MCPB_PROMPT_DISCOVERY_JSON` - -These allow deterministic testing without launching the server. - ### `mcpb pack [output]` Packs a directory into a MCPB extension file. @@ -109,52 +89,6 @@ The command automatically: - Excludes common development files (.git, node_modules/.cache, .DS_Store, etc.) - Creates a compressed .mcpb file (ZIP with maximum compression) -#### Capability Discovery (Tools & Prompts) - -During packing, the CLI launches your server (based on `server.mcp_config.command` + `args`) and uses the official C# MCP client to request both tool and prompt listings. It compares the discovered tool names (`tools` array) and prompt names (`prompts` array) with those declared in `manifest.json`. - -If they differ: - -- Default: packing fails with an error explaining the mismatch. -- `--force`: continue packing despite any mismatch (does not modify the manifest). -- `--update`: overwrite the `tools` and/or `prompts` list in `manifest.json` with the discovered sets (also sets `tools_generated: true` and/or `prompts_generated: true`) and persists the discovered descriptions plus prompt arguments/text when available. -- `--no-discover`: skip dynamic discovery entirely (useful offline or when the server cannot be executed locally). - -Environment overrides for tests/CI: - -- `MCPB_TOOL_DISCOVERY_JSON` JSON array of tool names. -- `MCPB_PROMPT_DISCOVERY_JSON` JSON array of prompt names. - If either is set, the server process is not launched for that capability. - -#### Referenced File Validation - -Before launching the server or writing the archive, `mcpb pack` now validates that certain files referenced in `manifest.json` actually exist relative to the extension directory: - -- `icon` (if specified) -- `server.entry_point` -- Path-like `server.mcp_config.command` values (those containing `/`, `\\`, `${__dirname}`, starting with `./` or `..`, or ending in common script/binary extensions such as `.js`, `.py`, `.exe`) -- Each file in `screenshots` (if specified) - -If any of these files are missing, packing fails immediately with an error like `Missing icon file: icon.png`. This happens before dynamic capability discovery so you get fast feedback on manifest inaccuracies. - -Commands (e.g. `node`, `python`) that are not path-like are not validated—they are treated as executables resolved by the environment. - -Examples: - -```bash -## Fail if mismatch -mcpb pack . - -# Force success even if mismatch -mcpb pack . --force - -## Update manifest.json to discovered tools/prompts -mcpb pack . --update - -# Skip discovery (behaves like legacy pack) -mcpb pack . --no-discover -``` - ### `mcpb sign ` Signs a MCPB extension file with a certificate. diff --git a/dotnet/CLI.md b/dotnet/CLI.md new file mode 100644 index 0000000..234e7ad --- /dev/null +++ b/dotnet/CLI.md @@ -0,0 +1,398 @@ +# 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. + +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 index 0039a5b..1e5af84 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -1,35 +1,44 @@ # MCPB .NET CLI -Experimental .NET port of the MCPB 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. -## Build +## Quick Usage + +Install the CLI globally and walk through the workflow in a single PowerShell session: ```pwsh -cd dotnet/mcpb -dotnet build -c Release -``` +dotnet tool install -g mcpb.cli -## Install as local tool +# 1. Create a manifest (or edit an existing one) +mcpb init my-extension -```pwsh -cd dotnet/mcpb -dotnet pack -c Release -# Find generated nupkg in bin/Release -dotnet tool install --global Mcpb.Cli --add-source ./bin/Release +# 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 ``` -## Commands +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 manifest.json | -| `mcpb validate [manifest\|directory] [--dirname path] [--discover] [--update] [--verbose]` | Validate manifest and related assets | -| `mcpb pack [directory] [output]` | Create .mcpb archive | -| `mcpb unpack [outputDir]` | Extract archive | -| `mcpb sign [--cert cert.pem --key key.pem --self-signed]` | Sign bundle | -| `mcpb verify ` | Verify signature | -| `mcpb info ` | Show bundle info (and signature) | -| `mcpb unsign ` | Remove signature | +| 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 @@ -40,6 +49,10 @@ When you run `mcpb validate --update` or `mcpb pack --update`, the tool captures - `--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/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/Commands/ManifestCommandHelpers.cs b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs index f598146..3496566 100644 --- a/dotnet/mcpb/Commands/ManifestCommandHelpers.cs +++ b/dotnet/mcpb/Commands/ManifestCommandHelpers.cs @@ -20,6 +20,18 @@ internal static class ManifestCommandHelpers { private static readonly TimeSpan DiscoveryTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan DiscoveryInitializationTimeout = TimeSpan.FromSeconds(15); + private static readonly IReadOnlyDictionary> 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, @@ -27,13 +39,15 @@ internal record CapabilityDiscoveryResult( McpbInitializeResult? InitializeResponse, McpbToolsListResult? ToolsListResponse, string? ReportedServerName, - string? ReportedServerVersion); + string? ReportedServerVersion + ); internal record CapabilityComparisonResult( bool NamesDiffer, bool MetadataDiffer, List SummaryTerms, - List Messages) + List Messages + ) { public bool HasDifferences => NamesDiffer || MetadataDiffer; } @@ -42,7 +56,8 @@ internal record StaticResponseComparisonResult( bool InitializeDiffers, bool ToolsListDiffers, List SummaryTerms, - List Messages) + List Messages + ) { public bool HasDifferences => InitializeDiffers || ToolsListDiffers; } @@ -99,7 +114,11 @@ private static object FilterNullProperties(JsonElement element) } } - internal static List ValidateReferencedFiles(McpbManifest manifest, string baseDir, Action? verboseLog = null) + internal static List ValidateReferencedFiles( + McpbManifest manifest, + string baseDir, + Action? verboseLog = null + ) { var errors = new List(); if (manifest.Server == null) @@ -113,13 +132,16 @@ internal static List ValidateReferencedFiles(McpbManifest manifest, stri static bool IsSystem32Path(string value, out string normalizedAbsolute) { normalizedAbsolute = string.Empty; - if (string.IsNullOrWhiteSpace(value)) return false; + if (string.IsNullOrWhiteSpace(value)) + return false; try { var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); - if (string.IsNullOrWhiteSpace(windowsDir)) return false; + if (string.IsNullOrWhiteSpace(windowsDir)) + return false; var candidate = value.Replace('/', '\\'); - if (!Path.IsPathRooted(candidate)) return false; + if (!Path.IsPathRooted(candidate)) + return false; var full = Path.GetFullPath(candidate); var system32 = Path.Combine(windowsDir, "System32"); if (full.StartsWith(system32, StringComparison.OrdinalIgnoreCase)) @@ -151,7 +173,9 @@ bool TryResolveManifestPath(string rawPath, string category, out string resolved } if (Path.IsPathRooted(rawPath)) { - errors.Add($"{category} path must be relative or reside under Windows\\System32: {rawPath}"); + errors.Add( + $"{category} path must be relative or reside under Windows\\System32: {rawPath}" + ); return false; } if (rawPath.Contains('\\')) @@ -176,7 +200,8 @@ string Resolve(string rel) void CheckFile(string? relativePath, string category) { - if (string.IsNullOrWhiteSpace(relativePath)) return; + if (string.IsNullOrWhiteSpace(relativePath)) + return; if (!TryResolveManifestPath(relativePath, category, out var resolved)) { return; @@ -204,12 +229,15 @@ void CheckFile(string? relativePath, string category) { var cmd = command!; verboseLog?.Invoke($"Resolving server command {cmd}"); - bool pathLike = cmd.Contains('/') || cmd.Contains('\\') || - cmd.StartsWith("${__dirname}", StringComparison.OrdinalIgnoreCase) || - cmd.StartsWith("./") || cmd.StartsWith("..") || - cmd.EndsWith(".js", StringComparison.OrdinalIgnoreCase) || - cmd.EndsWith(".py", StringComparison.OrdinalIgnoreCase) || - cmd.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); + bool pathLike = + cmd.Contains('/') + || cmd.Contains('\\') + || cmd.StartsWith("${__dirname}", StringComparison.OrdinalIgnoreCase) + || cmd.StartsWith("./") + || cmd.StartsWith("..") + || cmd.EndsWith(".js", StringComparison.OrdinalIgnoreCase) + || cmd.EndsWith(".py", StringComparison.OrdinalIgnoreCase) + || cmd.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); if (pathLike) { var expanded = ExpandToken(cmd, baseDir); @@ -231,7 +259,8 @@ void CheckFile(string? relativePath, string category) { foreach (var shot in manifest.Screenshots) { - if (string.IsNullOrWhiteSpace(shot)) continue; + if (string.IsNullOrWhiteSpace(shot)) + continue; verboseLog?.Invoke($"Checking screenshot {shot}"); CheckFile(shot, "screenshot"); } @@ -257,25 +286,38 @@ void CheckFile(string? relativePath, string category) 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 defaultLocalePath = resourcePath.Replace( + "${locale}", + defaultLocale, + StringComparison.OrdinalIgnoreCase + ); var resolved = Resolve(defaultLocalePath); - verboseLog?.Invoke($"Ensuring localization resources exist for default locale at {resolved}"); - + 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}"); + errors.Add( + $"Missing localization resources for default locale: {defaultLocalePath}" + ); } } return errors; } - internal static List ValidateLocalizationCompleteness(McpbManifest manifest, string baseDir, HashSet? rootProps = null, Action? verboseLog = null) + internal static List ValidateLocalizationCompleteness( + McpbManifest manifest, + string baseDir, + HashSet? rootProps = null, + Action? verboseLog = null + ) { var errors = new List(); - + if (manifest.Localization == null) return errors; @@ -290,37 +332,63 @@ internal static List ValidateLocalizationCompleteness(McpbManifest manif var localizableProperties = new List(); if (rootProps != null) { - if (rootProps.Contains("display_name") && !string.IsNullOrWhiteSpace(manifest.DisplayName)) + if ( + rootProps.Contains("display_name") + && !string.IsNullOrWhiteSpace(manifest.DisplayName) + ) localizableProperties.Add("display_name"); - if (rootProps.Contains("description") && !string.IsNullOrWhiteSpace(manifest.Description)) + if ( + rootProps.Contains("description") + && !string.IsNullOrWhiteSpace(manifest.Description) + ) localizableProperties.Add("description"); - if (rootProps.Contains("long_description") && !string.IsNullOrWhiteSpace(manifest.LongDescription)) + if ( + rootProps.Contains("long_description") + && !string.IsNullOrWhiteSpace(manifest.LongDescription) + ) localizableProperties.Add("long_description"); - if (rootProps.Contains("author") && !string.IsNullOrWhiteSpace(manifest.Author?.Name)) + if (rootProps.Contains("author") && !string.IsNullOrWhiteSpace(manifest.Author?.Name)) localizableProperties.Add("author.name"); - if (rootProps.Contains("keywords") && manifest.Keywords != null && manifest.Keywords.Count > 0) + 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"); + 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) + 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 @@ -340,8 +408,11 @@ internal static List ValidateLocalizationCompleteness(McpbManifest manif } var localeJson = File.ReadAllText(filePath); - var localeResource = JsonSerializer.Deserialize(localeJson, McpbJsonContext.Default.McpbLocalizationResource); - + var localeResource = JsonSerializer.Deserialize( + localeJson, + McpbJsonContext.Default.McpbLocalizationResource + ); + if (localeResource == null) { errors.Add($"Failed to parse locale file: {filePath}"); @@ -355,10 +426,14 @@ internal static List ValidateLocalizationCompleteness(McpbManifest manif { "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 + "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) @@ -370,16 +445,19 @@ internal static List ValidateLocalizationCompleteness(McpbManifest manif // Check tool descriptions if (toolsWithDescriptions.Count > 0) { - var localizedTools = localeResource.Tools ?? new List(); + var localizedTools = + localeResource.Tools ?? new List(); foreach (var tool in toolsWithDescriptions) { - var found = localizedTools.Any(t => - t.Name == tool.Name && - !string.IsNullOrWhiteSpace(t.Description)); - + 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})"); + errors.Add( + $"Missing localized description for tool '{tool.Name}' in {locale} ({filePath})" + ); } } } @@ -387,17 +465,22 @@ internal static List ValidateLocalizationCompleteness(McpbManifest manif // Check prompt descriptions if (promptsWithDescriptions.Count > 0) { - var localizedPrompts = localeResource.Prompts ?? new List(); + 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)); - + 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})"); + errors.Add( + $"Missing localized description for prompt '{prompt.Name}' in {locale} ({filePath})" + ); } } } @@ -411,10 +494,14 @@ internal static List ValidateLocalizationCompleteness(McpbManifest manif return errors; } - private static List<(string locale, string filePath)> FindLocaleFiles(string resourcePattern, string baseDir, string defaultLocale) + 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) @@ -422,10 +509,11 @@ internal static List ValidateLocalizationCompleteness(McpbManifest manif var beforePlaceholder = resourcePattern.Substring(0, patternIndex); var afterPlaceholder = resourcePattern.Substring(patternIndex + "${locale}".Length); - + var lastSlash = beforePlaceholder.LastIndexOfAny(new[] { '/', '\\' }); - string dirPath, filePrefix; - + string dirPath, + filePrefix; + if (lastSlash >= 0) { dirPath = beforePlaceholder.Substring(0, lastSlash); @@ -437,19 +525,21 @@ internal static List ValidateLocalizationCompleteness(McpbManifest manif filePrefix = beforePlaceholder; } - var fullDirPath = string.IsNullOrEmpty(dirPath) ? baseDir : Path.Combine(baseDir, dirPath.Replace('/', Path.DirectorySeparatorChar)); - + 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)) { @@ -470,13 +560,20 @@ internal static async Task DiscoverCapabilitiesAsync( string dir, McpbManifest manifest, Action? logInfo, - Action? logWarning) + 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) + if ( + overrideTools != null + || overridePrompts != null + || overrideInitialize != null + || overrideToolsList != null + ) { return new CapabilityDiscoveryResult( overrideTools ?? new List(), @@ -484,17 +581,35 @@ internal static async Task DiscoverCapabilitiesAsync( overrideInitialize, overrideToolsList, null, - null); + null + ); } - var cfg = manifest.Server?.McpConfig ?? throw new InvalidOperationException("Manifest server.mcp_config missing"); + 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"); + if (string.IsNullOrWhiteSpace(command)) + throw new InvalidOperationException("Manifest server.mcp_config.command empty"); var rawArgs = cfg.Args ?? new List(); - command = ExpandToken(command, dir); - var args = rawArgs.Select(a => ExpandToken(a, dir)).Where(a => !string.IsNullOrWhiteSpace(a)).ToList(); + var providedUserConfig = userConfigOverrides ?? EmptyUserConfigOverrides; + EnsureRequiredUserConfigProvided(manifest, command, rawArgs, providedUserConfig); + + 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]); + for (int i = 0; i < args.Count; i++) + args[i] = NormalizePathForPlatform(args[i]); Dictionary? env = null; if (cfg.Env != null && cfg.Env.Count > 0) @@ -502,7 +617,7 @@ internal static async Task DiscoverCapabilitiesAsync( env = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kv in cfg.Env) { - var expanded = ExpandToken(kv.Value, dir); + var expanded = ExpandToken(kv.Value, dir, providedUserConfig); env[kv.Key] = NormalizePathForPlatform(expanded); } } @@ -510,10 +625,10 @@ internal static async Task DiscoverCapabilitiesAsync( var toolInfos = new List(); var promptInfos = new List(); McpbInitializeResult? initializeResponse = null; - McpbToolsListResult? toolsListResponse = null; - var clientCreated = false; - string? reportedServerName = null; - string? reportedServerVersion = null; + McpbToolsListResult? toolsListResponse = null; + var clientCreated = false; + string? reportedServerName = null; + string? reportedServerVersion = null; bool supportsToolsList = true; bool supportsPromptsList = true; try @@ -522,18 +637,25 @@ internal static async Task DiscoverCapabilitiesAsync( IDictionary? envVars = null; if (env != null) { - envVars = new Dictionary(env.ToDictionary(kv => kv.Key, kv => (string?)kv.Value), StringComparer.OrdinalIgnoreCase); + 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)}"); + 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) @@ -543,7 +665,8 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to try { - var logParams = notification.Params.Deserialize(); + var logParams = + notification.Params.Deserialize(); if (logParams == null) { return ValueTask.CompletedTask; @@ -556,14 +679,21 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to { message = dataElement.GetString(); } - else if (dataElement.ValueKind != JsonValueKind.Null && dataElement.ValueKind != JsonValueKind.Undefined) + 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 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) { @@ -580,7 +710,9 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to } catch (Exception ex) { - logWarning?.Invoke($"Failed to process MCP server log notification: {ex.Message}"); + logWarning?.Invoke( + $"Failed to process MCP server log notification: {ex.Message}" + ); } return ValueTask.CompletedTask; @@ -593,14 +725,19 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to { NotificationHandlers = new[] { - new KeyValuePair>( - NotificationMethods.LoggingMessageNotification, - HandleServerLog) - } - } + new KeyValuePair< + string, + Func + >(NotificationMethods.LoggingMessageNotification, HandleServerLog), + }, + }, }; - await using var client = await McpClient.CreateAsync(transport, clientOptions, cancellationToken: cts.Token); + await using var client = await McpClient.CreateAsync( + transport, + clientOptions, + cancellationToken: cts.Token + ); reportedServerName = client.ServerInfo?.Name; reportedServerVersion = client.ServerInfo?.Version; clientCreated = true; @@ -637,14 +774,16 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to serverInfo = FilterNullProperties(infoElement); } - var instructions = string.IsNullOrWhiteSpace(client.ServerInstructions) ? null : client.ServerInstructions; + var instructions = string.IsNullOrWhiteSpace(client.ServerInstructions) + ? null + : client.ServerInstructions; initializeResponse = new McpbInitializeResult { ProtocolVersion = client.NegotiatedProtocolVersion, Capabilities = capabilities, ServerInfo = serverInfo, - Instructions = instructions + Instructions = instructions, }; } catch (Exception ex) @@ -658,14 +797,17 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to } catch (OperationCanceledException) { - logWarning?.Invoke("MCP server ping timed out during discovery; aborting capability checks."); + logWarning?.Invoke( + "MCP server ping timed out during discovery; aborting capability checks." + ); return new CapabilityDiscoveryResult( DeduplicateTools(toolInfos), DeduplicatePrompts(promptInfos), initializeResponse, toolsListResponse, reportedServerName, - reportedServerVersion); + reportedServerVersion + ); } catch (Exception ex) { @@ -684,7 +826,8 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to initializeResponse, toolsListResponse, reportedServerName, - reportedServerVersion); + reportedServerVersion + ); } IList? tools = null; @@ -712,7 +855,9 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to } else { - logInfo?.Invoke("Server capabilities did not include 'tools'; skipping tools/list request."); + logInfo?.Invoke( + "Server capabilities did not include 'tools'; skipping tools/list request." + ); } if (tools != null) @@ -741,11 +886,14 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to foreach (var tool in tools) { - if (string.IsNullOrWhiteSpace(tool.Name)) continue; + if (string.IsNullOrWhiteSpace(tool.Name)) + continue; var manifestTool = new McpbManifestTool { Name = tool.Name, - Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description + Description = string.IsNullOrWhiteSpace(tool.Description) + ? null + : tool.Description, }; toolInfos.Add(manifestTool); } @@ -775,23 +923,28 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to } else { - logInfo?.Invoke("Server capabilities did not include 'prompts'; skipping prompts/list request."); + 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; + 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) + 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() + .ToList(), }; if (manifestPrompt.Arguments != null && manifestPrompt.Arguments.Count == 0) { @@ -799,12 +952,17 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to } try { - var promptResult = await client.GetPromptAsync(prompt.Name, cancellationToken: cts.Token); + 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."); + logWarning?.Invoke( + $"Prompt '{prompt.Name}' content fetch timed out during discovery." + ); manifestPrompt.Text = string.Empty; } catch (Exception ex) @@ -815,7 +973,9 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to } else { - logWarning?.Invoke($"Prompt '{prompt.Name}' content fetch failed: {ex.Message}"); + logWarning?.Invoke( + $"Prompt '{prompt.Name}' content fetch failed: {ex.Message}" + ); } manifestPrompt.Text = string.Empty; } @@ -845,7 +1005,8 @@ ValueTask HandleServerLog(JsonRpcNotification notification, CancellationToken to initializeResponse, toolsListResponse, reportedServerName, - reportedServerVersion); + reportedServerVersion + ); } private static void LogMcpFailure(string operation, Exception ex, Action? logWarning) @@ -879,7 +1040,13 @@ private static string FormatMcpError(Exception ex) { var message = ex.Message; var type = ex.GetType(); - if (string.Equals(type.FullName, "ModelContextProtocol.McpProtocolException", StringComparison.Ordinal)) + if ( + string.Equals( + type.FullName, + "ModelContextProtocol.McpProtocolException", + StringComparison.Ordinal + ) + ) { var errorCodeProperty = type.GetProperty("ErrorCode"); if (errorCodeProperty?.GetValue(ex) is Enum errorCode) @@ -896,49 +1063,212 @@ private static string FormatMcpError(Exception ex) internal static string NormalizePathForPlatform(string value) { - if (string.IsNullOrEmpty(value)) return value; - if (value.Contains("://")) return value; - if (value.StartsWith("-")) return 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) + internal static string ExpandToken( + string value, + string dir, + IReadOnlyDictionary>? userConfigOverrides = null + ) { - if (string.IsNullOrEmpty(value)) return value; + 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 desktop = SafeGetSpecial( + Environment.SpecialFolder.Desktop, + Path.Combine(home, "Desktop") + ); + string documents = SafeGetSpecial( + Environment.SpecialFolder.MyDocuments, + Path.Combine(home, "Documents") + ); string downloads = Path.Combine(home, "Downloads"); string sep = Path.DirectorySeparatorChar.ToString(); - return Regex.Replace(value, "\\$\\{([^}]+)\\}", m => - { - var token = m.Groups[1].Value; - if (string.Equals(token, "__dirname", StringComparison.OrdinalIgnoreCase)) return dir.Replace('\\', '/'); - if (string.Equals(token, "HOME", StringComparison.OrdinalIgnoreCase)) return home; - if (string.Equals(token, "DESKTOP", StringComparison.OrdinalIgnoreCase)) return desktop; - if (string.Equals(token, "DOCUMENTS", StringComparison.OrdinalIgnoreCase)) return documents; - if (string.Equals(token, "DOWNLOADS", StringComparison.OrdinalIgnoreCase)) return downloads; - if (string.Equals(token, "pathSeparator", StringComparison.OrdinalIgnoreCase) || token == "/") return sep; - if (token.StartsWith("user_config.", StringComparison.OrdinalIgnoreCase)) return string.Empty; - return m.Value; - }); + 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; } + 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; + if (string.IsNullOrWhiteSpace(json)) + return null; try { using var doc = JsonDocument.Parse(json); - if (doc.RootElement.ValueKind != JsonValueKind.Array) return null; + if (doc.RootElement.ValueKind != JsonValueKind.Array) + return null; var list = new List(); foreach (var el in doc.RootElement.EnumerateArray()) { @@ -952,17 +1282,21 @@ private static string SafeGetSpecial(Environment.SpecialFolder folder, string fa continue; } - if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty("name", out var nameProp) || nameProp.ValueKind != JsonValueKind.String) + 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 - }; + var tool = new McpbManifestTool { Name = nameProp.GetString() ?? string.Empty }; - if (el.TryGetProperty("description", out var descProp) && descProp.ValueKind == JsonValueKind.String) + if ( + el.TryGetProperty("description", out var descProp) + && descProp.ValueKind == JsonValueKind.String + ) { var desc = descProp.GetString(); tool.Description = string.IsNullOrWhiteSpace(desc) ? null : desc; @@ -982,22 +1316,29 @@ private static string SafeGetSpecial(Environment.SpecialFolder folder, string fa private static List? TryParsePromptOverride(string envVar) { var json = Environment.GetEnvironmentVariable(envVar); - if (string.IsNullOrWhiteSpace(json)) return null; + if (string.IsNullOrWhiteSpace(json)) + return null; try { using var doc = JsonDocument.Parse(json); - if (doc.RootElement.ValueKind != JsonValueKind.Array) return null; + 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 }); + 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) + if ( + el.ValueKind != JsonValueKind.Object + || !el.TryGetProperty("name", out var nameProp) + || nameProp.ValueKind != JsonValueKind.String + ) { continue; } @@ -1005,16 +1346,22 @@ private static string SafeGetSpecial(Environment.SpecialFolder folder, string fa var prompt = new McpbManifestPrompt { Name = nameProp.GetString() ?? string.Empty, - Text = string.Empty + Text = string.Empty, }; - if (el.TryGetProperty("description", out var descProp) && descProp.ValueKind == JsonValueKind.String) + 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) + if ( + el.TryGetProperty("arguments", out var argsProp) + && argsProp.ValueKind == JsonValueKind.Array + ) { var args = new List(); foreach (var arg in argsProp.EnumerateArray()) @@ -1022,13 +1369,17 @@ private static string SafeGetSpecial(Environment.SpecialFolder folder, string fa if (arg.ValueKind == JsonValueKind.String) { var argName = arg.GetString(); - if (!string.IsNullOrWhiteSpace(argName)) args.Add(argName!); + 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) + if ( + el.TryGetProperty("text", out var textProp) + && textProp.ValueKind == JsonValueKind.String + ) { prompt.Text = textProp.GetString() ?? string.Empty; } @@ -1047,7 +1398,8 @@ private static string SafeGetSpecial(Environment.SpecialFolder folder, string fa private static McpbInitializeResult? TryParseInitializeOverride(string envVar) { var json = Environment.GetEnvironmentVariable(envVar); - if (string.IsNullOrWhiteSpace(json)) return null; + if (string.IsNullOrWhiteSpace(json)) + return null; try { return JsonSerializer.Deserialize(json, McpbJsonContext.Default.McpbInitializeResult); @@ -1061,7 +1413,8 @@ private static string SafeGetSpecial(Environment.SpecialFolder folder, string fa private static McpbToolsListResult? TryParseToolsListOverride(string envVar) { var json = Environment.GetEnvironmentVariable(envVar); - if (string.IsNullOrWhiteSpace(json)) return null; + if (string.IsNullOrWhiteSpace(json)) + return null; try { return JsonSerializer.Deserialize(json, McpbJsonContext.Default.McpbToolsListResult); @@ -1085,16 +1438,21 @@ private static List DeduplicateTools(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)); + 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 + Description = string.IsNullOrWhiteSpace(description) ? null : description, }; } - private static List DeduplicatePrompts(IEnumerable prompts) + private static List DeduplicatePrompts( + IEnumerable prompts + ) { return prompts .Where(p => !string.IsNullOrWhiteSpace(p.Name)) @@ -1110,30 +1468,37 @@ private static McpbManifestPrompt MergePromptGroup(IEnumerable 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 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; + : 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 + Text = text, }; } private static string ExtractPromptText(GetPromptResult? promptResult) { - if (promptResult?.Messages == null) return string.Empty; + if (promptResult?.Messages == null) + return string.Empty; var builder = new StringBuilder(); foreach (var message in promptResult.Messages) { - if (message?.Content == null) continue; + if (message?.Content == null) + continue; AppendContentBlocks(builder, message.Content); } return builder.ToString(); @@ -1162,81 +1527,108 @@ private static void AppendContentBlocks(StringBuilder builder, object content) private static void AppendText(StringBuilder builder, TextContentBlock? textBlock) { - if (textBlock == null || string.IsNullOrWhiteSpace(textBlock.Text)) return; - if (builder.Length > 0) builder.AppendLine(); + 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) + internal static List GetToolMetadataDifferences( + IEnumerable? manifestTools, + IEnumerable discoveredTools + ) { var differences = new List(); - if (manifestTools == null) return differences; + 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 (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)})."); + 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) + internal static List GetPromptMetadataDifferences( + IEnumerable? manifestPrompts, + IEnumerable discoveredPrompts + ) { var differences = new List(); - if (manifestPrompts == null) return differences; + 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 (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)})."); + 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)})."); + 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})."); + 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) + internal static List GetPromptTextWarnings( + IEnumerable? manifestPrompts, + IEnumerable discoveredPrompts + ) { var warnings = new List(); - var manifestByName = manifestPrompts? - .Where(p => !string.IsNullOrWhiteSpace(p.Name)) + 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 (string.IsNullOrWhiteSpace(prompt.Name)) + continue; var discoveredText = NormalizeString(prompt.Text); - if (discoveredText != null) continue; + if (discoveredText != null) + continue; McpbManifestPrompt? existing = null; if (manifestByName != null) @@ -1246,21 +1638,28 @@ internal static List GetPromptTextWarnings(IEnumerable MergePromptMetadata(IEnumerable? manifestPrompts, IEnumerable discoveredPrompts) + internal static List MergePromptMetadata( + IEnumerable? manifestPrompts, + IEnumerable discoveredPrompts + ) { - var manifestByName = manifestPrompts? - .Where(p => !string.IsNullOrWhiteSpace(p.Name)) + var manifestByName = manifestPrompts + ?.Where(p => !string.IsNullOrWhiteSpace(p.Name)) .ToDictionary(p => p.Name, StringComparer.Ordinal); return discoveredPrompts @@ -1272,17 +1671,19 @@ internal static List MergePromptMetadata(IEnumerable 0 - ? new List(p.Arguments) - : null, - Text = mergedText + Arguments = + p.Arguments != null && p.Arguments.Count > 0 + ? new List(p.Arguments) + : null, + Text = mergedText, }; }) .ToList(); @@ -1290,15 +1691,17 @@ internal static List MergePromptMetadata(IEnumerable? manifestTools, - IEnumerable discoveredTools) + 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(); + var manifestNames = + manifestTools + ?.Where(t => !string.IsNullOrWhiteSpace(t.Name)) + .Select(t => t.Name) + .ToList() ?? new List(); manifestNames.Sort(StringComparer.Ordinal); var discoveredNames = discoveredTools @@ -1337,15 +1740,17 @@ internal static CapabilityComparisonResult CompareTools( internal static CapabilityComparisonResult ComparePrompts( IEnumerable? manifestPrompts, - IEnumerable discoveredPrompts) + 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(); + var manifestNames = + manifestPrompts + ?.Where(p => !string.IsNullOrWhiteSpace(p.Name)) + .Select(p => p.Name) + .ToList() ?? new List(); manifestNames.Sort(StringComparer.Ordinal); var discoveredNames = discoveredPrompts @@ -1385,7 +1790,8 @@ internal static CapabilityComparisonResult ComparePrompts( internal static StaticResponseComparisonResult CompareStaticResponses( McpbManifest manifest, McpbInitializeResult? initializeResponse, - McpbToolsListResult? toolsListResponse) + McpbToolsListResult? toolsListResponse + ) { var summaryTerms = new List(); var messages = new List(); @@ -1402,13 +1808,17 @@ internal static StaticResponseComparisonResult CompareStaticResponses( { initializeDiffers = true; summaryTerms.Add("static_responses.initialize"); - messages.Add("Missing _meta.static_responses.initialize; discovery returned an initialize payload."); + 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."); + messages.Add( + "_meta.static_responses.initialize differs from discovered initialize payload." + ); } } @@ -1418,23 +1828,33 @@ internal static StaticResponseComparisonResult CompareStaticResponses( { toolsListDiffers = true; summaryTerms.Add("static_responses.tools/list"); - messages.Add("Missing _meta.static_responses.\"tools/list\"; discovery returned a tools/list payload."); + 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."); + messages.Add( + "_meta.static_responses.\"tools/list\" differs from discovered tools/list payload." + ); } } - return new StaticResponseComparisonResult(initializeDiffers, toolsListDiffers, summaryTerms, messages); + return new StaticResponseComparisonResult( + initializeDiffers, + toolsListDiffers, + summaryTerms, + messages + ); } internal static bool ApplyWindowsMetaStaticResponses( McpbManifest manifest, McpbInitializeResult? initializeResponse, - McpbToolsListResult? toolsListResponse) + McpbToolsListResult? toolsListResponse + ) { if (initializeResponse == null && toolsListResponse == null) { @@ -1474,13 +1894,15 @@ internal static bool ApplyWindowsMetaStaticResponses( return true; } - private static bool StringEqualsNormalized(string? a, string? b) - => string.Equals(NormalizeString(a), NormalizeString(b), StringComparison.Ordinal); + 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 string? NormalizeString(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; - private static Dictionary BuildInitializeStaticResponse(McpbInitializeResult response) + private static Dictionary BuildInitializeStaticResponse( + McpbInitializeResult response + ) { var result = new Dictionary(); if (!string.IsNullOrWhiteSpace(response.ProtocolVersion)) @@ -1504,13 +1926,15 @@ private static Dictionary BuildInitializeStaticResponse(McpbInit private static IReadOnlyList NormalizeArguments(IReadOnlyCollection? args) { - if (args == null || args.Count == 0) return Array.Empty(); + 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 "[]"; + if (args.Count == 0) + return "[]"; return "[" + string.Join(", ", args) + "]"; } @@ -1535,7 +1959,9 @@ private static McpbWindowsMeta GetWindowsMeta(McpbManifest manifest) try { var json = JsonSerializer.Serialize(windowsMetaDict, McpbJsonContext.WriteOptions); - return JsonSerializer.Deserialize(json, McpbJsonContext.Default.McpbWindowsMeta) as McpbWindowsMeta ?? new McpbWindowsMeta(); + return JsonSerializer.Deserialize(json, McpbJsonContext.Default.McpbWindowsMeta) + as McpbWindowsMeta + ?? new McpbWindowsMeta(); } catch { @@ -1545,18 +1971,25 @@ private static McpbWindowsMeta GetWindowsMeta(McpbManifest manifest) private static void SetWindowsMeta(McpbManifest manifest, McpbWindowsMeta windowsMeta) { - manifest.Meta ??= new Dictionary>(StringComparer.Ordinal); + 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(); + 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; + if (ReferenceEquals(a, b)) + return true; + if (a == null || b == null) + return false; try { diff --git a/dotnet/mcpb/Commands/PackCommand.cs b/dotnet/mcpb/Commands/PackCommand.cs index 86ab48c..16b7231 100644 --- a/dotnet/mcpb/Commands/PackCommand.cs +++ b/dotnet/mcpb/Commands/PackCommand.cs @@ -1,276 +1,469 @@ +using System; using System.CommandLine; using System.IO.Compression; using System.Security.Cryptography; using System.Text; -using Mcpb.Core; using System.Text.Json; -using Mcpb.Json; using System.Text.RegularExpressions; +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" + 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 dirArg = new Argument( + "directory", + () => Directory.GetCurrentDirectory(), + "Extension directory" + ); var outputArg = new Argument("output", () => null, "Output .mcpb path"); - var forceOpt = new Option(name: "--force", description: "Proceed even if discovered tools differ from manifest"); - var updateOpt = new Option(name: "--update", description: "Update manifest tools list to match dynamically discovered tools"); - var noDiscoverOpt = new Option(name: "--no-discover", description: "Skip dynamic tool discovery (for offline / testing)"); - var cmd = new Command("pack", "Pack a directory into an MCPB extension") { dirArg, outputArg, forceOpt, updateOpt, noDiscoverOpt }; - cmd.SetHandler(async (string? directory, string? output, bool force, bool update, bool noDiscover) => + var 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." + ) { - 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) + 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 + ) => { - try + if ( + !UserConfigOptionParser.TryParse( + userConfigRaw, + out var userConfigOverrides, + out var parseError + ) + ) { - var result = await ManifestCommandHelpers.DiscoverCapabilitiesAsync( - dir, - manifest, - message => Console.WriteLine(message), - warning => Console.Error.WriteLine($"WARNING: {warning}")); - discoveredTools = result.Tools; - discoveredPrompts = result.Prompts; - discoveredInitResponse = result.InitializeResponse; - discoveredToolsListResponse = result.ToolsListResponse; + Console.Error.WriteLine($"ERROR: {parseError}"); + Environment.ExitCode = 1; + return; } - catch (Exception ex) + var dir = Path.GetFullPath(directory ?? Directory.GetCurrentDirectory()); + if (!Directory.Exists(dir)) { - Console.Error.WriteLine($"WARNING: Tool discovery failed: {ex.Message}"); + 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; } - } - 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) + 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) { - mismatchOccurred = true; - Console.WriteLine("Tool list mismatch:"); - Console.WriteLine(" Manifest: [" + string.Join(", ", manifestTools) + "]"); - Console.WriteLine(" Discovered: [" + string.Join(", ", discoveredToolNames) + "]"); + foreach (var err in fileErrors) + Console.Error.WriteLine($"ERROR: {err}"); + Environment.ExitCode = 1; + return; } - var metadataDiffs = ManifestCommandHelpers.GetToolMetadataDifferences(manifest.Tools, discoveredTools); - if (metadataDiffs.Count > 0) + // Attempt dynamic discovery unless opted out (tools & prompts) + List? discoveredTools = null; + List? discoveredPrompts = null; + McpbInitializeResult? discoveredInitResponse = null; + McpbToolsListResult? discoveredToolsListResponse = null; + if (!noDiscover) { - mismatchOccurred = true; - Console.WriteLine("Tool metadata mismatch:"); - foreach (var diff in metadataDiffs) + try { - Console.WriteLine(" " + diff); + 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}"); } } - } - 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) + bool mismatchOccurred = false; + if (discoveredTools != null) { - mismatchOccurred = true; - Console.WriteLine("Prompt list mismatch:"); - Console.WriteLine(" Manifest: [" + string.Join(", ", manifestPrompts) + "]"); - Console.WriteLine(" Discovered: [" + string.Join(", ", discoveredPromptNames) + "]"); + 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); + } + } } - var metadataDiffs = ManifestCommandHelpers.GetPromptMetadataDifferences(manifest.Prompts, discoveredPrompts); - if (metadataDiffs.Count > 0) + if (discoveredPrompts != null) { - mismatchOccurred = true; - Console.WriteLine("Prompt metadata mismatch:"); - foreach (var diff in metadataDiffs) + 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) { - Console.WriteLine(" " + diff); + 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}"); } } - var promptWarnings = ManifestCommandHelpers.GetPromptTextWarnings(manifest.Prompts, discoveredPrompts); - foreach (var warning in promptWarnings) + bool metaUpdated = false; + if (update) { - Console.Error.WriteLine($"WARNING: {warning}"); + metaUpdated = ManifestCommandHelpers.ApplyWindowsMetaStaticResponses( + manifest, + discoveredInitResponse, + discoveredToolsListResponse + ); } - } - bool metaUpdated = false; - if (update) - { - metaUpdated = ManifestCommandHelpers.ApplyWindowsMetaStaticResponses( - manifest, - discoveredInitResponse, - discoveredToolsListResponse); - } - - if (mismatchOccurred) - { - if (update) + if (mismatchOccurred) { - if (discoveredTools != null) + if (update) { - manifest.Tools = discoveredTools - .Select(t => new McpbManifestTool - { - Name = t.Name, - Description = t.Description - }) - .ToList(); - manifest.ToolsGenerated ??= false; + 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." + ); + } } - if (discoveredPrompts != null) + else if (!force) { - manifest.Prompts = ManifestCommandHelpers.MergePromptMetadata(manifest.Prompts, discoveredPrompts); - manifest.PromptsGenerated ??= false; + Console.Error.WriteLine( + "ERROR: Discovered capabilities differ from manifest. Use --force to ignore or --update to rewrite manifest." + ); + Environment.ExitCode = 1; + return; } - File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions)); - Console.WriteLine("Updated manifest.json capabilities to match discovered results."); - if (metaUpdated) + else { - Console.WriteLine("Updated manifest.json _meta static_responses to match discovered results."); + Console.WriteLine( + "Proceeding due to --force despite capability mismatches." + ); } } - 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 + else if (metaUpdated && update) { - Console.WriteLine("Proceeding due to --force despite capability mismatches."); + File.WriteAllText( + manifestPath, + JsonSerializer.Serialize(manifest, McpbJsonContext.WriteOptions) + ); + Console.WriteLine( + "Updated manifest.json _meta static_responses to match discovered results." + ); } - } - 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"); + // 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)); + 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) + // 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) { - 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; + 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]" + ); } - 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) + using var mem = new MemoryStream(); + using (var zip = new ZipArchive(mem, ZipArchiveMode.Create, true, Encoding.UTF8)) { - var entry = zip.CreateEntry(rel, CompressionLevel.SmallestSize); - using var es = entry.Open(); - await using var fs = File.OpenRead(filePath); - await fs.CopyToAsync(es); + 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 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); + 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; } + 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) + 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; } + if (ShouldExclude(rel, additionalPatterns)) + { + ignoredCount++; + continue; + } results.Add((file, rel)); } return results; @@ -285,7 +478,8 @@ private static bool Matches(string relative, IEnumerable patterns) { foreach (var pattern in patterns) { - if (GlobMatch(relative, pattern)) return true; + if (GlobMatch(relative, pattern)) + return true; } return false; } @@ -294,7 +488,8 @@ 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) + var regex = System + .Text.RegularExpressions.Regex.Escape(pattern) .Replace(@"\*\*\/", @"(?:(?:.+/)?)") .Replace(@"\*", @"[^/]*") .Replace(@"\?", @"."); @@ -304,7 +499,8 @@ private static bool GlobMatch(string text, string pattern) private static List LoadIgnoreFile(string baseDir) { var path = Path.Combine(baseDir, ".mcpbignore"); - if (!File.Exists(path)) return new List(); + if (!File.Exists(path)) + return new List(); return File.ReadAllLines(path) .Select(l => l.Trim()) .Where(l => l.Length > 0 && !l.StartsWith("#")) @@ -313,7 +509,11 @@ private static List LoadIgnoreFile(string baseDir) 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"; + 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) @@ -322,9 +522,11 @@ private static string SanitizeFileName(string name) sanitized = RegexReplace(sanitized, "[^A-Za-z0-9-_.]", ""); sanitized = RegexReplace(sanitized, "-+", "-"); sanitized = sanitized.Trim('-'); - if (sanitized.Length > 100) sanitized = sanitized.Substring(0, 100); + if (sanitized.Length > 100) + sanitized = sanitized.Substring(0, 100); return sanitized; } - private static string RegexReplace(string input, string pattern, string replacement) => System.Text.RegularExpressions.Regex.Replace(input, pattern, replacement); + private static string RegexReplace(string input, string pattern, string replacement) => + System.Text.RegularExpressions.Regex.Replace(input, pattern, replacement); } 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 index 9bf7229..0aafba1 100644 --- a/dotnet/mcpb/Commands/ValidateCommand.cs +++ b/dotnet/mcpb/Commands/ValidateCommand.cs @@ -1,5 +1,6 @@ -using System.CommandLine; +using System; using System.Collections.Generic; +using System.CommandLine; using System.IO; using System.Linq; using System.Text; @@ -13,450 +14,669 @@ public static class ValidateCommand { public static Command Create() { - var manifestArg = new Argument("manifest", description: "Path to manifest.json or its directory"); + 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 cmd = new Command("validate", "Validate an MCPB manifest file") { manifestArg, dirnameOpt, updateOpt, discoverOpt, verboseOpt }; - cmd.SetHandler(async (string? path, string? dirname, bool update, bool discover, bool verbose) => + 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." + ) { - if (update && discover) - { - Console.Error.WriteLine("ERROR: --discover and --update cannot be used together."); - Environment.ExitCode = 1; - return; - } - if (string.IsNullOrWhiteSpace(path)) + 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 (!string.IsNullOrWhiteSpace(dirname)) - { - path = Path.Combine(dirname, "manifest.json"); - } - else + if ( + !UserConfigOptionParser.TryParse( + userConfigRaw, + out var userConfigOverrides, + out var parseError + ) + ) { - Console.Error.WriteLine("ERROR: Manifest path or --dirname must be specified."); + Console.Error.WriteLine($"ERROR: {parseError}"); 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 (update && discover) { - if (verbose) Console.WriteLine($"VERBOSE: {message}"); + Console.Error.WriteLine( + "ERROR: --discover and --update cannot be used together." + ); + Environment.ExitCode = 1; + return; } - var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath)); - if (discover && string.IsNullOrWhiteSpace(dirname)) + if (string.IsNullOrWhiteSpace(path)) { - dirname = manifestDirectory; if (!string.IsNullOrWhiteSpace(dirname)) { - LogVerbose($"Using manifest directory {dirname} for discovery"); + path = Path.Combine(dirname, "manifest.json"); + } + else + { + Console.Error.WriteLine( + "ERROR: Manifest path or --dirname must be specified." + ); + Environment.ExitCode = 1; + return; } } - if (update && string.IsNullOrWhiteSpace(dirname)) + var manifestPath = path!; + if (Directory.Exists(manifestPath)) { - Console.Error.WriteLine("ERROR: --update requires --dirname to locate manifest assets."); - Environment.ExitCode = 1; - return; + manifestPath = Path.Combine(manifestPath, "manifest.json"); } - if (Environment.GetEnvironmentVariable("MCPB_DEBUG_VALIDATE") == "1") + if (!File.Exists(manifestPath)) { - Console.WriteLine($"DEBUG: Read manifest {manifestPath} length={json.Length}"); + Console.Error.WriteLine($"ERROR: File not found: {manifestPath}"); + Environment.ExitCode = 1; + return; } - LogVerbose($"Validating manifest JSON at {manifestPath}"); - - static void PrintWarnings(IEnumerable warnings, bool toError) + string json; + try { - foreach (var warning in warnings) + json = File.ReadAllText(manifestPath); + void LogVerbose(string message) { - var msg = string.IsNullOrEmpty(warning.Path) - ? warning.Message - : $"{warning.Path}: {warning.Message}"; - if (toError) Console.Error.WriteLine($"Warning: {msg}"); - else Console.WriteLine($"Warning: {msg}"); + if (verbose) + Console.WriteLine($"VERBOSE: {message}"); } - } - - 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 manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath)); + if (discover && string.IsNullOrWhiteSpace(dirname)) { - var pfx = string.IsNullOrEmpty(issue.Path) ? "" : issue.Path + ": "; - Console.Error.WriteLine($" - {pfx}{issue.Message}"); + dirname = manifestDirectory; + if (!string.IsNullOrWhiteSpace(dirname)) + { + LogVerbose($"Using manifest directory {dirname} for discovery"); + } } - 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)) + if (update && string.IsNullOrWhiteSpace(dirname)) { - Console.Error.WriteLine($"ERROR: Directory not found: {baseDir}"); - PrintWarnings(currentWarnings, toError: true); + Console.Error.WriteLine( + "ERROR: --update requires --dirname to locate manifest assets." + ); Environment.ExitCode = 1; return; } - - if (update) + if (Environment.GetEnvironmentVariable("MCPB_DEBUG_VALIDATE") == "1") { - assetPathsNormalized = NormalizeManifestAssetPaths(manifest) || assetPathsNormalized; + Console.WriteLine( + $"DEBUG: Read manifest {manifestPath} length={json.Length}" + ); } + LogVerbose($"Validating manifest JSON at {manifestPath}"); - var fileErrors = ManifestCommandHelpers.ValidateReferencedFiles(manifest, baseDir, LogVerbose); - foreach (var err in fileErrors) + static void PrintWarnings(IEnumerable warnings, bool toError) { - var message = err; - if (err.Contains("path must use '/' as directory separator", StringComparison.Ordinal)) + foreach (var warning in warnings) { - message += " Run validate --update to normalize manifest asset paths."; + var msg = string.IsNullOrEmpty(warning.Path) + ? warning.Message + : $"{warning.Path}: {warning.Message}"; + if (toError) + Console.Error.WriteLine($"Warning: {msg}"); + else + Console.WriteLine($"Warning: {msg}"); } - additionalErrors.Add($"ERROR: {message}"); } - var localizationErrors = ManifestCommandHelpers.ValidateLocalizationCompleteness(manifest, baseDir, rootProps, LogVerbose); - foreach (var err in localizationErrors) + 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) { - additionalErrors.Add($"ERROR: {err}"); + 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; } - if (discover) + 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)) { - LogVerbose("Running discovery to compare manifest capabilities"); + rootProps = new HashSet(StringComparer.OrdinalIgnoreCase); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + foreach (var p in doc.RootElement.EnumerateObject()) + rootProps.Add(p.Name); } - void RecordDiscoveryViolation(string message) + + if (!string.IsNullOrWhiteSpace(dirname)) { - if (string.IsNullOrWhiteSpace(message)) return; - discoveryViolations.Add(message); - if (update) + var baseDir = Path.GetFullPath(dirname); + LogVerbose($"Checking referenced assets using directory {baseDir}"); + if (!Directory.Exists(baseDir)) { - Console.WriteLine(message); + Console.Error.WriteLine($"ERROR: Directory not found: {baseDir}"); + PrintWarnings(currentWarnings, toError: true); + Environment.ExitCode = 1; + return; } - else + + if (update) { - LogVerbose(message); + assetPathsNormalized = + NormalizeManifestAssetPaths(manifest) || assetPathsNormalized; } - } - 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}"); - }); - } - 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 fileErrors = ManifestCommandHelpers.ValidateReferencedFiles( + manifest, + baseDir, + LogVerbose + ); + foreach (var err in fileErrors) { - var originalManifestName = manifest.Name; - if (!string.Equals(originalManifestName, reportedServerName, StringComparison.Ordinal)) + var message = err; + if ( + err.Contains( + "path must use '/' as directory separator", + 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."); - } + message += + " Run validate --update to normalize manifest asset paths."; } + additionalErrors.Add($"ERROR: {message}"); } - if (!string.IsNullOrWhiteSpace(reportedServerVersion)) + var localizationErrors = + ManifestCommandHelpers.ValidateLocalizationCompleteness( + manifest, + baseDir, + rootProps, + LogVerbose + ); + foreach (var err in localizationErrors) { - 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."); - } - } + additionalErrors.Add($"ERROR: {err}"); } - 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 (discover) { - if (!comparison.HasDifferences) return; - discoveryMismatchOccurred = true; - foreach (var term in comparison.SummaryTerms) - { - mismatchSummary.Add(term); - } - foreach (var message in comparison.Messages) - { - RecordDiscoveryViolation(message); - } + LogVerbose("Running discovery to compare manifest capabilities"); } - - 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) + void RecordDiscoveryViolation(string message) { - discoveryMismatchOccurred = true; - foreach (var term in staticResponseComparison.SummaryTerms) + if (string.IsNullOrWhiteSpace(message)) + return; + discoveryViolations.Add(message); + if (update) { - mismatchSummary.Add(term); + Console.WriteLine(message); } - foreach (var message in staticResponseComparison.Messages) + else { - RecordDiscoveryViolation(message); + LogVerbose(message); } } - - var promptWarnings = ManifestCommandHelpers.GetPromptTextWarnings(manifest.Prompts, discoveredPrompts); - foreach (var warning in promptWarnings) + ManifestCommandHelpers.CapabilityDiscoveryResult? discovery = null; + try { - Console.Error.WriteLine($"WARNING: {warning}"); + 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 + ); } - - bool toolUpdatesApplied = false; - bool promptUpdatesApplied = false; - bool metaUpdated = false; - - if (update) + catch (ManifestCommandHelpers.UserConfigRequiredException ex) { - 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; - } + additionalErrors.Add($"ERROR: {ex.Message}"); } - - - if (update && (toolUpdatesApplied || promptUpdatesApplied || metaUpdated || manifestNameUpdated || assetPathsNormalized || manifestVersionUpdated)) + catch (InvalidOperationException ex) { - 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); + additionalErrors.Add($"ERROR: {ex.Message}"); + } + catch (Exception ex) + { + additionalErrors.Add($"ERROR: MCP discovery failed: {ex.Message}"); + } - if (updatedErrors.Count > 0) + 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)) { - Console.Error.WriteLine("ERROR: Updated manifest validation failed (updated file written):\n"); - foreach (var issue in updatedErrors) + var originalManifestName = manifest.Name; + if ( + !string.Equals( + originalManifestName, + reportedServerName, + StringComparison.Ordinal + ) + ) { - var pfx = string.IsNullOrEmpty(issue.Path) ? string.Empty : issue.Path + ": "; - Console.Error.WriteLine($" - {pfx}{issue.Message}"); + 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." + ); + } } - 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)) + if (!string.IsNullOrWhiteSpace(reportedServerVersion)) { - Console.Error.WriteLine("ERROR: Updated manifest still differs from discovered capability names (updated file written)."); - PrintWarnings(updatedWarnings, toError: true); - Environment.ExitCode = 1; - return; + 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." + ); + } + } } - if (!string.IsNullOrWhiteSpace(reportedServerVersion) && - !string.Equals(updatedManifest.Version, reportedServerVersion, StringComparison.Ordinal)) + 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 + ) { - Console.Error.WriteLine("ERROR: Updated manifest version still differs from MCP server version (updated file written)."); - PrintWarnings(updatedWarnings, toError: true); - Environment.ExitCode = 1; - return; + if (!comparison.HasDifferences) + return; + discoveryMismatchOccurred = true; + foreach (var term in comparison.SummaryTerms) + { + mismatchSummary.Add(term); + } + foreach (var message in comparison.Messages) + { + RecordDiscoveryViolation(message); + } } - var remainingToolDiffs = ManifestCommandHelpers.GetToolMetadataDifferences(updatedManifest.Tools, discoveredTools); - var remainingPromptDiffs = ManifestCommandHelpers.GetPromptMetadataDifferences(updatedManifest.Prompts, discoveredPrompts); - if (remainingToolDiffs.Count > 0 || remainingPromptDiffs.Count > 0) + 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) { - Console.Error.WriteLine("ERROR: Updated manifest metadata still differs from discovered results (updated file written)."); - PrintWarnings(updatedWarnings, toError: true); - Environment.ExitCode = 1; - return; + discoveryMismatchOccurred = true; + foreach (var term in staticResponseComparison.SummaryTerms) + { + mismatchSummary.Add(term); + } + foreach (var message in staticResponseComparison.Messages) + { + RecordDiscoveryViolation(message); + } } - 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) + var promptWarnings = ManifestCommandHelpers.GetPromptTextWarnings( + manifest.Prompts, + discoveredPrompts + ); + foreach (var warning in promptWarnings) { - Console.WriteLine("Updated manifest name to match MCP server name."); + Console.Error.WriteLine($"WARNING: {warning}"); } - if (manifestVersionUpdated) + + bool toolUpdatesApplied = false; + bool promptUpdatesApplied = false; + bool metaUpdated = false; + + if (update) { - Console.WriteLine("Updated manifest version to match MCP server version."); + 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 (assetPathsNormalized) + + if ( + update + && ( + toolUpdatesApplied + || promptUpdatesApplied + || metaUpdated + || manifestNameUpdated + || assetPathsNormalized + || manifestVersionUpdated + ) + ) { - Console.WriteLine("Normalized manifest asset paths to use forward slashes."); - } + 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; + } - manifest = updatedManifest; - currentWarnings = new List(updatedWarnings); + 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) + if (discoveryMismatchOccurred && !update) { - additionalErrors.Add("ERROR: Discovered capabilities differ from manifest" + summarySuffix + "."); + 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." + ); + } } - else + + if (additionalErrors.Count > 0) { - additionalErrors.Add("ERROR: Discovered capabilities differ from manifest" + summarySuffix + ". Use --discover to verify or Use --update to rewrite manifest."); + foreach (var err in additionalErrors) + { + Console.Error.WriteLine(err); + } + PrintWarnings(currentWarnings, toError: true); + Environment.ExitCode = 1; + return; } - } - if (additionalErrors.Count > 0) + Console.WriteLine("Manifest is valid!"); + PrintWarnings(currentWarnings, toError: false); + Console.Out.Flush(); + } + catch (Exception ex) { - foreach (var err in additionalErrors) - { - Console.Error.WriteLine(err); - } - PrintWarnings(currentWarnings, toError: true); + Console.Error.WriteLine($"ERROR: {ex.Message}"); 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); + }, + manifestArg, + dirnameOpt, + updateOpt, + discoverOpt, + verboseOpt, + userConfigOpt + ); return cmd; } @@ -466,16 +686,24 @@ private static bool NormalizeManifestAssetPaths(McpbManifest manifest) 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; + 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; + if (string.IsNullOrWhiteSpace(value)) + return false; + if (LooksLikeAbsolutePath(value)) + return false; var original = value; var trimmed = value.TrimStart('/', '\\'); @@ -530,7 +758,8 @@ static bool NormalizeRelativePath(ref string value) { foreach (var icon in manifest.Icons) { - if (icon == null || string.IsNullOrWhiteSpace(icon.Src)) continue; + if (icon == null || string.IsNullOrWhiteSpace(icon.Src)) + continue; var src = icon.Src; if (NormalizeRelativePath(ref src)) { @@ -552,4 +781,4 @@ static bool NormalizeRelativePath(ref string value) return changed; } -} \ No newline at end of file +}