diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 000000000..8df4abb3d --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "mcpb.cli": { + "version": "0.3.5", + "commands": [ + "mcpb" + ] + } + } +} diff --git a/.gitignore b/.gitignore index 0bcbeb000..98531be0a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ generated/ /docs/commandline .DS_Store .vscode/mcp.json +.vscode/settings.json eng/tools/ToolDescriptionEvaluator/prompts.json eng/tools/ToolDescriptionEvaluator/results.md diff --git a/docs/design/mcpb-packaging-and-signing-via-esrp.md b/docs/design/mcpb-packaging-and-signing-via-esrp.md new file mode 100644 index 000000000..7c43f07ab --- /dev/null +++ b/docs/design/mcpb-packaging-and-signing-via-esrp.md @@ -0,0 +1,657 @@ +# Design Document: MCPB Packaging and Signing via ESRP + +## Problem Statement + +Microsoft's internal signing service (ESRP) does not currently support MCPB files directly. However, ESRP supports **generic PKCS#7 detached signing** (`Pkcs7DetachedSign` operation), which produces a standalone `.p7s` signature file that can be appended to any file format. Since MCPB uses PKCS#7 signatures, we can use ESRP's detached signing capability to sign MCPB files and convert the resulting signature to MCPB format. + +Additionally, we need to package our MCP servers into the MCPB format using the official MCPB CLI tool. For best compatibility and performance, we should use the **trimmed version** of the server produced by the release pipeline, as it is as lightweight as we can make the server today and includes the .NET runtime. + +--- + +## Goals + +1. **Package MCP servers into MCPB format** using the official MCPB CLI tool +2. **Use trimmed server binaries** for lightweight and self-contained packages +3. **Enable MCPB signing** for all MCP servers in this repository using existing ESRP infrastructure +4. **Automate the entire process** as part of the release pipeline +5. **Support multiple servers** (Azure.Mcp.Server, Fabric.Mcp.Server, Template.Mcp.Server, etc.) +6. **Maintain compatibility** with the official MCPB signature format per the [mcpb CLI specification](https://github.com/modelcontextprotocol/mcpb/blob/main/CLI.md) +7. **Provide verification** that the signed MCPB files are valid using `mcpb verify` + +## Non-Goals + +1. Modifying ESRP to natively support MCPB (out of scope - requires Microsoft-wide coordination) +2. Creating a custom signing infrastructure +3. Supporting non-Microsoft signing services + +--- + +## Background + +### MCPB Format Overview + +MCP Bundles (`.mcpb`) are ZIP archives containing a local MCP server and a `manifest.json` that describes the server and its capabilities. The format is similar to Chrome extensions (`.crx`) or VS Code extensions (`.vsix`), enabling end users to install local MCP servers with a single click. + +**MCPB CLI Installation:** +```bash +# Restore from local tool manifest (recommended for our pipeline, version pinned in .config/dotnet-tools.json) +dotnet tool restore + +# Or via npm +npm install -g @anthropic-ai/mcpb +``` + +**Binary Bundle Structure:** +``` +bundle.mcpb (ZIP file) +├── manifest.json # Required: Bundle metadata and configuration +├── server/ # Server files +│ ├── azmcp # Unix executable +│ ├── azmcp.exe # Windows executable +│ └── [dependencies] # All required DLLs and resources +└── servericon.png # Bundle icon +``` + +### Why Trimmed Binaries? + +For optimal performance with AI clients like Claude Desktop, we use **trimmed server binaries** produced by the release pipeline: + +| Aspect | Full Build | Trimmed Build | +|--------|-----------|---------------| +| Size | ~150+ MB | ~100 MB | +| Startup Time | Slower | Faster | +| Dependencies | All included | Only used code | +| Client Experience | Standard | Lightweight | + +The trimmed version removes unused code and dependencies through .NET's IL trimming, resulting in faster startup times critical for responsive AI client interactions. + +### ESRP Pkcs7DetachedSign Operation + +ESRP's `Pkcs7DetachedSign` operation creates a detached PKCS#7 signature for any file: + +| Aspect | Details | +|--------|---------| +| Operation Code | `Pkcs7DetachedSign` | +| Key Code | `CP-230012` (Microsoft Corporation certificate) | +| Input | Any file (`.mcpb`, `.zip`, etc.) | +| Output | Detached `.p7s` signature file | +| Signature Type | DER-encoded PKCS#7/CMS | + +The detached signature signs the entire file content and can be converted to MCPB's embedded signature format. + +### MCPB Signature Structure + +Per the [mcpb CLI documentation](https://github.com/modelcontextprotocol/mcpb/blob/main/CLI.md#signature-format), MCPB uses PKCS#7 (Cryptographic Message Syntax) for digital signatures: + +``` +[Original MCPB ZIP content] +MCPB_SIG_V1 +[4-byte little-endian length prefix] +[DER-encoded PKCS#7 signature] +MCPB_SIG_END +``` + +> **Note on documentation vs implementation**: The official CLI.md documentation shows a simplified format with Base64-encoded signatures and no length prefix. However, we verified the actual source code for both the [NPM/TypeScript implementation](https://github.com/modelcontextprotocol/mcpb/blob/main/src/node/sign.ts) and the [C#/.NET implementation](https://github.com/asklar/mcpb/blob/user/asklar/dotnet/dotnet/mcpb/Commands/SignCommand.cs) - both use **DER-encoded (raw binary) signatures with a 4-byte little-endian length prefix**, not Base64. In TypeScript, `createSignatureBlock` writes `writeUInt32LE(pkcs7Signature.length)` and `extractSignatureBlock` reads with `readUInt32LE`. In C#, `CreateSignatureBlock` uses `BitConverter.GetBytes(pkcs7.Length)` and `ExtractSignatureBlock` reads with `BitConverter.ToInt32`. This length prefix is essential for unambiguous parsing, as raw DER bytes could otherwise contain sequences that match the `MCPB_SIG_END` marker. + +This approach allows: +- Backward compatibility (unsigned MCPB files are valid ZIP files) +- Easy signature verification and removal +- Support for certificate chains with intermediate certificates + +### ZIP Compatibility (EOCD Comment Length) + +Standard ZIP parsers locate the End of Central Directory (EOCD) record and expect the file to end at `EOCD_offset + 22 + comment_length`. When the MCPB signature block is naively appended, the EOCD `comment_length` remains 0, but extra bytes (the signature block) follow — causing strict ZIP parsers to reject the file. + +**Affected tools:** Claude Desktop uses a strict ZIP parser that rejects files with unexpected trailing bytes: +``` +Failed to read or unzip file: Invalid comment length. Expected: 3997. Found: 0. +``` + +**Our fix:** Before ESRP signing, we set the EOCD `comment_length` to a fixed constant (`MAX_SIG_BLOCK_SIZE = 16384`). After signing, we pad the signature block with zero bytes to exactly that size. The result is a valid ZIP where the "comment" bytes happen to be the MCPB signature block. + +``` +[ZIP entries + Central Directory + EOCD (comment_length=16384)] +[MCPB_SIG_V1][4-byte len][DER signature][zero padding][MCPB_SIG_END] +|<-------- exactly 16384 bytes = EOCD comment_length ----------->| +``` + +**Why this works end-to-end:** +| Step | Behavior | +|------|----------| +| ESRP signing | Signs the MCPB *after* EOCD is updated, so the hash covers `comment_length=16384` | +| `mcpb verify` | Extracts `originalContent` = everything before `MCPB_SIG_V1` = the EOCD-modified file = what ESRP signed ✅ | +| `mcpb unpack` | Strips signature, reads ZIP entries normally ✅ | +| ZIP parsers | See `comment_length=16384` and 16384 bytes follow EOCD ✅ | +| Claude Desktop | Treats it as a valid ZIP and installs successfully ✅ | + +### ESRP Detached Signing Output + +- Input: `file.mcpb` (staged using the `.signature.p7s` extension for ESRP processing, with EOCD comment_length pre-set) +- Output: `file.signature.p7s` (detached PKCS#7/CMS signature) + +The signature signs the entire file content (including the pre-set EOCD comment_length). ESRP replaces the staged file with the signature, so we stage a copy with a `.signature.p7s` extension and keep the original `.mcpb` intact. + +--- + +## Proposed Solution + +### High-Level Workflow + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ MCPB Packaging and Signing Workflow │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Build Phase (existing pipeline) │ +│ ┌──────────────────────┐ │ +│ │ dotnet publish │ │ +│ │ (trimmed binaries) │ ──────► /publish/win-x64/ │ +│ └──────────────────────┘ /publish/linux-x64/ │ +│ /publish/osx-x64/ │ +│ /publish/osx-arm64/ │ +│ │ +│ 2. Prepare MCPB Structure │ +│ ┌────────────────────────┐ │ +│ │ Copy manifest.json │ │ +│ │ Bundle server binaries │ ──────► /mcpb-staging/{platform}/ │ +│ │ Copy icon │ ├── manifest.json │ +│ └────────────────────────┘ ├── server/ │ +│ │ └── [trimmed binaries] │ +│ └── servericon.png │ +│ │ +│ 3. Package with MCPB CLI │ +│ ┌──────────────────────────────┐ │ +│ │ mcpb validate │ │ +│ │ mcpb pack (updates manifest) │ ──────► {ServerName}-{platform}.mcpb │ +│ └──────────────────────────────┘ (unsigned) │ +│ │ +│ 4. ESRP Detached Signing (Pkcs7DetachedSign) │ +│ ┌──────────────────────┐ ┌──────────────────┐ │ +│ │ unsigned.mcpb │ ──ESRP──► signature.p7s │ │ +│ │ (Pkcs7DetachedSign) │ │ (detached sig) │ │ +│ └──────────────────────┘ └──────────────────┘ │ +│ │ +│ 5. Convert & Apply Signature │ +│ ┌──────────────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ unsigned.mcpb │ +│ signature.p7s│ ───► signed.mcpb │ │ +│ └──────────────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────┐ │ +│ │ MCPB_SIG_V1 │ │ +│ │ [length][signature] │ │ +│ │ MCPB_SIG_END │ │ +│ └────────────────────────┘ │ +│ │ +│ 6. Verification │ +│ ┌──────────────────────┐ │ +│ │ mcpb verify │ ──────► ✓ Valid signature │ +│ │ mcpb info │ │ +│ └──────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Component Design + +#### 1. MCPB CLI Installation + +The MCPB CLI is required for packaging and verification. Restore via local tool manifest (version pinned in `.config/dotnet-tools.json`): + +```bash +dotnet tool restore +``` + +The CLI provides essential commands: +- `mcpb init` - Create manifest.json interactively +- `mcpb validate` - Validate manifest against schema +- `mcpb pack` - Package directory into .mcpb file. Updates the manifest to include latest tool info. +- `mcpb sign` - Sign with certificate (not used - we use ESRP) +- `mcpb verify` - Verify signature of signed .mcpb +- `mcpb info` - Display information about .mcpb file + +#### 2. PowerShell Script: `Pack-Mcpb.ps1` + +Location: `eng/scripts/Pack-Mcpb.ps1` + +**Parameters:** +- `-ArtifactsPath`: Path to the build artifacts directory containing trimmed server binaries +- `-BuildInfoPath`: Path to the `build_info.json` file containing server and platform definitions +- `-OutputPath`: Output directory for `.mcpb` files +- `-KeepStagingDirectory`: Optional flag to keep staging directory for debugging + +**Supported Platforms** (determined by `build_info.json`): +- `win-x64`, `win-arm64` (Windows) +- `linux-x64`, `linux-arm64` (Linux) +- `osx-x64`, `osx-arm64` (macOS) + +**Workflow:** +1. Create staging directory structure +2. Copy trimmed binaries to `server/` subdirectory +3. Copy `manifest.json` (uses `platform_overrides` in mcp_config for cross-platform support) +4. Copy icon and assets. Rename icon to `servericon.png`. +5. Copy LICENSE and NOTICE.txt into each bundle +6. Validate with `mcpb validate` +7. Package with `mcpb pack --update` (auto-populates tools array) + +#### 3. Modular Pipeline Scripts + +The pipeline uses separate scripts for each signing phase, enabling better maintainability and testability: + +**`Stage-McpbForSigning.ps1`** - Location: `eng/scripts/Stage-McpbForSigning.ps1` +- Creates copies of `.mcpb` files with `.signature.p7s` extension for ESRP processing +- **Updates the ZIP EOCD comment length** to `MAX_SIG_BLOCK_SIZE` (16384) before staging, so that the signed file remains a valid ZIP (see [ZIP Compatibility](#zip-compatibility-eocd-comment-length)) +- Parameters: `-ArtifactsPath`, `-StagingPath` + +**`Apply-McpbSignatures.ps1`** - Location: `eng/scripts/Apply-McpbSignatures.ps1` +- Applies ESRP-generated `.p7s` signatures to MCPB files +- Contains internal `Convert-P7sToMcpbSignature` function for signature format conversion +- Wraps .p7s in MCPB signature format (MCPB_SIG_V1 + length + sig + **padding** + MCPB_SIG_END) +- **Pads the signature block** with zero bytes so the total block size equals `MAX_SIG_BLOCK_SIZE`, matching the pre-set EOCD comment length +- Parameters: `-ArtifactsPath`, `-OutputPath` + +**`Verify-McpbSignatures.ps1`** - Location: `eng/scripts/Verify-McpbSignatures.ps1` +- Verifies all signed MCPB files using `mcpb verify` +- Fails pipeline if any signature verification fails +- Parameters: `-ArtifactsPath` + +#### 4. Signature Conversion Logic + +``` +Input: signature.p7s (DER-encoded PKCS#7) +Output: MCPB signature block (padded to MAX_SIG_BLOCK_SIZE) + +Algorithm: +1. Read .p7s file as bytes +2. Calculate length (4-byte little-endian) +3. Calculate padding = MAX_SIG_BLOCK_SIZE - (11 + 4 + sig_length + 12) +4. Construct signature block: + - "MCPB_SIG_V1" (11 bytes, ASCII) + - Length prefix (4 bytes, little-endian) — actual signature length, NOT padded + - Signature bytes (from .p7s) + - Zero padding (padding bytes) + - "MCPB_SIG_END" (12 bytes, ASCII) +5. Append to original .mcpb content (which has EOCD comment_length = MAX_SIG_BLOCK_SIZE) +``` + +> The padding ensures the total appended block size equals the EOCD comment length, +> making the file a valid ZIP. The `mcpb` extraction logic reads exactly `sig_length` +> bytes via the 4-byte length prefix and ignores the padding. + +#### 5. Pipeline Integration + +Location: `eng/pipelines/templates/jobs/mcpb/pack-and-sign-mcpb.yml` + +**Template parameters:** +- `DependsOn`: Jobs that must complete before packaging (typically `Sign`) + +**Steps:** +1. Install MCPB CLI tool +2. Download signed binaries and build_info.json +3. Package all servers for all platforms using `Pack-Mcpb.ps1` +4. Stage files for ESRP signing using `Stage-McpbForSigning.ps1` +5. Submit to ESRP using Pkcs7DetachedSign operation +6. Apply signatures using `Apply-McpbSignatures.ps1` +7. Verify using `Verify-McpbSignatures.ps1` +8. Publish signed .mcpb artifacts to GitHub using inline PowerShell in `release-mcpb.yml` + +--- + +## Implementation Details + +### File Structure + +``` +eng/ +├── scripts/ +│ ├── Pack-Mcpb.ps1 # MCPB packaging script (uses build_info.json) +│ ├── Stage-McpbForSigning.ps1 # Stage files for ESRP signing +│ ├── Apply-McpbSignatures.ps1 # Apply .p7s signatures (includes Convert-P7sToMcpbSignature) +│ ├── Verify-McpbSignatures.ps1 # Verify signatures using mcpb CLI +│ └── Update-ServerJsonMcpbHashes.ps1 # Compute SHA256 for MCP Registry +├── pipelines/ +│ └── templates/ +│ └── jobs/ +│ └── mcpb/ +│ ├── pack-and-sign-mcpb.yml # Pack and sign pipeline template +│ └── release-mcpb.yml # Release pipeline template +docs/ +└── mcpb-packaging-and-signing-via-esrp.md # This design document +servers/ +├── Azure.Mcp.Server/ +│ ├── mcpb/ +│ │ ├── manifest.json # MCPB manifest +│ │ └── servericon.png # Server icon +│ └── server.json # MCP Registry config with MCPB entries +├── Fabric.Mcp.Server/ +│ ├── mcpb/ +│ │ ├── manifest.json # MCPB manifest +│ │ └── servericon.png # Server icon +│ └── server.json # MCP Registry config with MCPB entries +└── Template.Mcp.Server/ + ├── mcpb/ + │ ├── manifest.json # MCPB manifest + │ └── servericon.png # Server icon + └── server.json # MCP Registry config with MCPB entries +``` + +### Integration with Build Info + +The MCPB packaging scripts integrate with the existing `build_info.json` infrastructure, which is the source of truth for all packaging scripts. This provides: + +- **Consistent versioning** across all package formats (npm, NuGet, VSIX, MCPB) +- **Platform discovery** from the build matrix +- **Server metadata** (name, cliName, description, icon path) +- **Artifact paths** for locating trimmed binaries + +The `build_info.json` file is created by `New-BuildInfo.ps1` and consumed by all `Pack-*.ps1` scripts. + +### Manifest Requirements + +Each server must have a complete `manifest.json` file in `servers/{ServerName}/mcpb/`. The manifest should: + +1. **Use `platform_overrides`** in `mcp_config` to handle cross-platform executable paths (Windows `.exe` vs Unix) +2. **Include all metadata** (name, version, description, author, etc.) +3. **Reference the correct icon** path relative to the manifest location + +The `mcpb pack` command will automatically populate the `tools` array from the server's MCP tool definitions during packaging. + +**Important Path Handling:** +- **`entry_point`**: Must be a relative path (e.g., `server/azmcp.exe`) without `${__dirname}`. The `mcpb pack` command verifies this file exists in the staging directory. +- **`mcp_config.command`**: Should use `${__dirname}` prefix (e.g., `${__dirname}/server/azmcp.exe`) for runtime path resolution. Both dotnet and npm mcpb validators accept this. + +The `Pack-Mcpb.ps1` script automatically adds the platform-specific extension (`.exe` for Windows) to both paths. + +**Important Manifest Version:** The manifest must use `"manifest_version": "0.3"` or higher to support the `_meta` field that `mcpb pack --update` adds. Using `0.2` will cause Claude Desktop to reject the bundle with "Unrecognized key(s) in object: '_meta'". + +**Example:** See [servers/Azure.Mcp.Server/mcpb/manifest.json](https://github.com/microsoft/mcp/blob/vcolin7/test-mcpb-signing/servers/Azure.Mcp.Server/mcpb/manifest.json) for a complete manifest. + +### Script: `Pack-Mcpb.ps1` + +Location: [`eng/scripts/Pack-Mcpb.ps1`](https://github.com/microsoft/mcp/blob/vcolin7/test-mcpb-signing/eng/scripts/Pack-Mcpb.ps1) + +This is the main packaging script that follows the same patterns as `Pack-Npm.ps1`, `Pack-Vsix.ps1`, etc. + +**Key behaviors:** +- Reads server list and platform definitions from `build_info.json` +- Filters to trimmed, non-native, non-special-purpose platforms +- Creates `.mcpb` files named `{ServerName}-{os}-{arch}.mcpb` (e.g., `Azure.Mcp.Server-win-x64.mcpb`) +- Validates manifest with `mcpb validate` before packaging +- Copies LICENSE and NOTICE.txt into each bundle + +### Script: `Apply-McpbSignatures.ps1` + +Location: [`eng/scripts/Apply-McpbSignatures.ps1`](https://github.com/microsoft/mcp/blob/vcolin7/test-mcpb-signing/eng/scripts/Apply-McpbSignatures.ps1) + +This script applies ESRP-generated `.p7s` signatures to MCPB files. It contains the `Convert-P7sToMcpbSignature` function internally for signature format conversion. + +**Key behaviors:** +- Finds all `.mcpb` files and their corresponding `.signature.p7s` files +- Converts PKCS#7 signatures to MCPB embedded format +- Preserves directory structure in output +- Reports success/failure counts + +### Pipeline Template: `pack-and-sign-mcpb.yml` + +Location: [`eng/pipelines/templates/jobs/mcpb/pack-and-sign-mcpb.yml`](https://github.com/microsoft/mcp/blob/vcolin7/test-mcpb-signing/eng/pipelines/templates/jobs/mcpb/pack-and-sign-mcpb.yml) + +This template orchestrates the packaging and signing workflow: + +1. **PackMcpb job**: Installs MCPB CLI, downloads trimmed binaries, packages all servers +2. **SignMcpb job**: Stages files, submits to ESRP, applies signatures, verifies with `mcpb verify` + +**Pipeline artifacts:** +- `mcpb-unsigned`: Unsigned MCPB packages (intermediate) +- `mcpb-signed`: Signed MCPB packages (final output) + +### Pipeline Template: `release-mcpb.yml` + +Location: [`eng/pipelines/templates/jobs/mcpb/release-mcpb.yml`](https://github.com/microsoft/mcp/blob/vcolin7/test-mcpb-signing/eng/pipelines/templates/jobs/mcpb/release-mcpb.yml) + +This template handles publishing signed MCPB packages to GitHub Release. The upload logic is inlined directly in the YAML because 1ES release jobs (`templateContext.type: releaseJob`) do not allow `checkout` steps. + +--- + +## Server Support Matrix + +| Server | Path | Notes | +|--------|------|-------| +| Azure.Mcp.Server | `servers/Azure.Mcp.Server/` | Azure server | ✅ manifest.json created | +| Fabric.Mcp.Server | `servers/Fabric.Mcp.Server/` | Fabric server | ✅ manifest.json created | +| Template.Mcp.Server | `servers/Template.Mcp.Server/` | Template for new servers | ✅ manifest.json created | + +All servers have complete `manifest.json` files with: +- `manifest_version: "0.3"` for Claude Desktop compatibility +- `platform_overrides` for cross-platform executable paths +- Proper `entry_point` and `mcp_config.command` paths +- Server icon (`servericon.png`) in the mcpb directory + +The signing script auto-discovers servers based on the `servers/` directory structure and the presence of `manifest.json` files. + +--- + +## Testing Strategy + +### Unit Tests + +1. **Signature format validation**: Verify the generated signature block matches MCPB spec +2. **Length prefix correctness**: Test with various signature sizes +3. **Binary integrity**: Ensure no corruption during file operations + +### Integration Tests + +1. **Round-trip test**: Sign → Verify → Unsign → Compare with original +2. **mcpb CLI compatibility**: Verify signed files work with `mcpb verify`, `mcpb info` +3. **Cross-platform**: Test on Windows, Linux, macOS + +### Pipeline Tests + +1. **Dry-run mode**: Test pipeline without actual ESRP submission +2. **Mock .p7s files**: Use pre-generated signatures for testing + +--- + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Signature format changes in mcpb spec | Medium | Version the signature format; monitor mcpb CLI updates | +| ESRP rate limiting during batch signing | Low | Implement retry logic with exponential backoff | +| Certificate expiration | Medium | Monitor cert validity; integrate with cert renewal alerts | +| Trimmed binaries missing dependencies | Medium | Test thoroughly on clean systems; include all required native libraries | +| MCPB CLI version incompatibility | Low | Pin CLI version; test updates in staging before production | +| ESRP replaces file with signature | Low | Use staging pattern: copy to `.signature.p7s` before signing | + +--- + +## Open Questions + +1. **Should we also support local/self-signed certificates for development?** + - Recommendation: Yes, use `mcpb sign --self-signed` for local testing + +2. **How to handle signature verification failures in the pipeline?** + - Recommendation: Fail the build immediately; do not publish unsigned artifacts + +3. **Should we store the .p7s files separately as artifacts?** + - Recommendation: Yes, for audit and debugging purposes + +4. **How should the manifest.json tools array be populated?** + - Recommendation: Generate dynamically from the server's tool definitions via the `mpcb pack` command + +5. **Should we support per-platform manifest variations?** + - Recommendation: Use a single manifest with platform_overrides in mcp_config for command paths + +--- + +## Implementation Phases + +### Phase 1: Core Scripts and Templates (Week 1) ✅ +- [x] Implement `Pack-Mcpb.ps1` for packaging (integrated with build_info.json) +- [x] Implement modular pipeline scripts: + - [x] `Stage-McpbForSigning.ps1` - Stage files for ESRP + - [x] `Apply-McpbSignatures.ps1` - Apply .p7s signatures (includes `Convert-P7sToMcpbSignature` function) + - [x] `Verify-McpbSignatures.ps1` - Verify using mcpb CLI +- [x] Verify manifest.json for Azure.Mcp.Server (uses manifest_version 0.3) +- [x] Add unit tests for signature conversion (`Apply-McpbSignatures.tests.ps1`) +- [x] Document local usage +- [x] Validate MCPB bundle works in Claude Desktop + +### Phase 2: Pipeline Integration (Week 2) ✅ +- [x] Create `pack-and-sign-mcpb.yml` pipeline template +- [x] Create `release-mcpb.yml` pipeline template +- [x] Integrate with existing release pipeline (`common.yml`, `sign-and-pack.yml`, `release.yml`) +- [x] Enable MCPB packaging for Azure.Mcp.Server (`build.yml`) +- [x] Test end-to-end with Azure.Mcp.Server (requires pipeline run) +- [x] Verify signatures with `mcpb verify` (requires pipeline run) + +### Phase 3: Multi-Server Support and MCP Registry (Week 3) ✅ +- [x] Add MCPB package entries to `server.json` with platform-specific URLs and SHA256 placeholders +- [x] Update `New-ServerJson.ps1` to populate MCPB package URLs from GitHub Release +- [x] Create `Update-ServerJsonMcpbHashes.ps1` to compute SHA256 hashes after signing +- [x] Update `update-mcp-repository.yml` to compute hashes before publishing to MCP Registry +- [x] Create manifest.json for Fabric.Mcp.Server +- [x] Create manifest.json for Template.Mcp.Server +- [ ] Test MCP Registry publishing end-to-end (requires pipeline run) + +--- + +## MCP Registry Publishing + +The MCP Registry requires MCPB packages to include: +1. A GitHub Release URL (`identifier`) pointing to the `.mcpb` file +2. A SHA256 hash (`fileSha256`) for file integrity verification + +### Supported Platforms + +Each server is packaged for **6 platforms**: + +| Platform | OS | Architecture | MCPB Filename Suffix | +|----------|----|--------------|-----------------------| +| `win-x64` | Windows | x86_64 | `-win-x64.mcpb` | +| `win-arm64` | Windows | arm64 | `-win-arm64.mcpb` | +| `linux-x64` | Linux | x86_64 | `-linux-x64.mcpb` | +| `linux-arm64` | Linux | arm64 | `-linux-arm64.mcpb` | +| `osx-x64` | macOS | x86_64 | `-osx-x64.mcpb` | +| `osx-arm64` | macOS | arm64 | `-osx-arm64.mcpb` | + +### server.json MCPB Entries + +Each platform has its own MCPB package entry in `server.json`. The placeholders (`<>` and `<>`) are replaced at release time: + +```json +{ + "registryType": "mcpb", + "identifier": "https://github.com/microsoft/mcp/releases/download/Azure.Mcp.Server-1.0.0/Azure.Mcp.Server-win-x64.mcpb", + "fileSha256": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", + "transport": { + "type": "stdio" + } +} +``` + +### SHA256 Hash Computation + +The `Update-ServerJsonMcpbHashes.ps1` script computes SHA256 hashes for signed MCPB files: + +```powershell +# Compute hash using PowerShell +(Get-FileHash -Path "Azure.Mcp.Server-win-x64.mcpb" -Algorithm SHA256).Hash.ToLower() + +# Or using openssl +openssl dgst -sha256 Azure.Mcp.Server-win-x64.mcpb +``` + +### Pipeline Flow + +``` +PackMcpb → SignMcpb → PublishMcpb (GitHub Release) → UpdateMcpRepository + ↓ + Download MCPB artifacts + ↓ + Compute SHA256 hashes + ↓ + Update server.json + ↓ + Publish to MCP Registry +``` + +--- + +## Local Development and Testing + +### Using Pack-Mcpb.ps1 (Recommended) + +The easiest way to create MCPB packages locally is to use the same script the pipeline uses: + +```powershell +# Step 1: Build trimmed server binaries +./eng/scripts/Build-Code.ps1 -ServerName "Azure.Mcp.Server" -SelfContained -ReleaseBuild -Trimmed + +# Step 2: Generate build_info.json +./eng/scripts/New-BuildInfo.ps1 -ServerName "Azure.Mcp.Server" + +# Step 3: Create MCPB packages for all platforms +./eng/scripts/Pack-Mcpb.ps1 + +# Output will be in .work/packages_mcpb/ +``` + +Or with custom paths: + +```powershell +./eng/scripts/Pack-Mcpb.ps1 ` + -ArtifactsPath ".work/build" ` + -BuildInfoPath ".work/build_info.json" ` + -OutputPath "./artifacts/mcpb" +``` + +### Validate and Inspect Packages + +```bash +# Show package information +mcpb info .work/packages_mcpb/Azure.Mcp.Server/Azure.Mcp.Server-win-x64.mcpb + +# Validate manifest without packing +mcpb validate servers/Azure.Mcp.Server/mcpb/ +``` + +### Self-Signed Testing + +```bash +# Sign with self-signed certificate for local testing +mcpb sign .work/packages_mcpb/Azure.Mcp.Server/Azure.Mcp.Server-win-x64.mcpb --self-signed + +# Verify (will show self-signed warning) +mcpb verify .work/packages_mcpb/Azure.Mcp.Server/Azure.Mcp.Server-win-x64.mcpb +``` + +### Testing with Claude Desktop + +1. Build and sign the MCPB package (self-signed for testing) +2. Double-click the `.mcpb` file to install in Claude Desktop +3. Verify the server appears in Claude's MCP server list +4. Test tool invocations + +--- + +## References + +- [MCPB Repository](https://github.com/modelcontextprotocol/mcpb) +- [MCPB Manifest Specification](https://github.com/modelcontextprotocol/mcpb/blob/main/MANIFEST.md) +- [MCPB CLI Documentation](https://github.com/modelcontextprotocol/mcpb/blob/main/CLI.md) +- [MCPB Signature Format Specification](https://github.com/modelcontextprotocol/mcpb/blob/main/CLI.md#signature-format) +- [PKCS#7 / CMS Standard (RFC 5652)](https://tools.ietf.org/html/rfc5652) + +--- + +## Approval + +- [ ] Security review (signing process) +- [ ] Pipeline team review +- [ ] Documentation review + +--- + +**Author:** Azure MCP Team +**Date:** February 9, 2026 +**Status:** Implementation Complete - Pending pipeline validation diff --git a/eng/README.md b/eng/README.md index 86827af9d..4c05aa7d3 100644 --- a/eng/README.md +++ b/eng/README.md @@ -190,6 +190,22 @@ Servers are packed into two npm package types: Zip files for each server in a release are added to the GitHub release assets. + +### MCPB (MCP Bundle) +- packed with [`Pack-Mcpb.ps1`](https://github.com/microsoft/mcp/blob/vcolin7/test-mcpb-signing/eng/scripts/Pack-Mcpb.ps1) +- signed via [`pack-and-sign-mcpb.yml`](https://github.com/microsoft/mcp/blob/vcolin7/test-mcpb-signing/eng/pipelines/templates/jobs/mcpb/pack-and-sign-mcpb.yml) +- released via [`release-mcpb.yml`](https://github.com/microsoft/mcp/blob/vcolin7/test-mcpb-signing/eng/pipelines/templates/jobs/mcpb/release-mcpb.yml) +- requires a `manifest.json` and `servericon.png` under `servers//mcpb/` + +MCPB files are signed MCP bundles that can be installed directly into Claude Desktop with a single click. Each platform (win-x64, linux-x64, osx-x64, osx-arm64) produces a separate `.mcpb` file containing trimmed server binaries. The bundles are signed using ESRP's Pkcs7DetachedSign operation and verified with the `mcpb verify` CLI. + +Signing pipeline scripts: +- [`Stage-McpbForSigning.ps1`](https://github.com/microsoft/mcp/blob/vcolin7/test-mcpb-signing/eng/scripts/Stage-McpbForSigning.ps1) - Prepares files for ESRP signing +- [`Apply-McpbSignatures.ps1`](https://github.com/microsoft/mcp/blob/vcolin7/test-mcpb-signing/eng/scripts/Apply-McpbSignatures.ps1) - Applies PKCS#7 signatures to MCPB files +- [`Verify-McpbSignatures.ps1`](https://github.com/microsoft/mcp/blob/vcolin7/test-mcpb-signing/eng/scripts/Verify-McpbSignatures.ps1) - Verifies signatures using mcpb CLI + +For design details, see [docs/mcpb-packaging-and-signing-via-esrp.md](https://github.com/microsoft/mcp/blob/vcolin7/test-mcpb-signing/docs/design/mcpb-packaging-and-signing-via-esrp.md). + # Server release process We only ship the repo's server projects, with each server stored under `servers/`. The release pipelines are driven by the server-specific `build.yml` files (for example, `servers/Azure.Mcp.Server/build.yml`). Those files opt a server into the artifact types we publish and pass the settings that flow through `/eng/pipelines/templates/common.yml`. @@ -202,7 +218,7 @@ We only ship the repo's server projects, with each server stored under `servers/ - **Expect post-release automation**: After a successful public release, `jobs/release.yml` runs `Update-Version.ps1` to increment the server version and open a PR. This PR must be merged before the next release can start. ## Server release configuration -The parameters in a server's `build.yml` decide what we produce. Set `PackageDocker: true` to include Docker images, `PackageVSIX: true` to include VS Code extensions. Every pipeline always creates the npm/npx and NuGet/dnx artifacts. +The parameters in a server's `build.yml` decide what we produce. Set `PackageDocker: true` to include Docker images, `PackageVSIX: true` to include VS Code extensions, `PackageMCPB: true` to include signed MCP bundles. Every pipeline always creates the npm/npx and NuGet/dnx artifacts. ## Project file properties Packaging and release scripts depend on metadata found in a build's build_info.json file, which is generated by `eng/scripts/New-BuildInfo.ps1` using properties from each server's project file. See [eng/scripts/Get-ProjectProperties.ps1](https://github.com/microsoft/mcp/blob/main/eng/scripts/Get-ProjectProperties.ps1) for a list of all supported project file properties. diff --git a/eng/pipelines/templates/common.yml b/eng/pipelines/templates/common.yml index 98c3dc58b..146f52dd5 100644 --- a/eng/pipelines/templates/common.yml +++ b/eng/pipelines/templates/common.yml @@ -21,6 +21,9 @@ parameters: - name: PackagePyPI type: boolean default: false +- name: PackageMCPB + type: boolean + default: false - name: TestTimeoutInMinutes type: number default: 30 @@ -122,6 +125,7 @@ extends: PackageDocker: ${{ parameters.PackageDocker }} PackageVSIX: ${{ parameters.PackageVSIX }} PackagePyPI: ${{ parameters.PackagePyPI }} + PackageMCPB: ${{ parameters.PackageMCPB }} - ${{ if eq(parameters.PublishTarget, 'public') }}: - stage: Release @@ -143,6 +147,7 @@ extends: PackageDocker: ${{ parameters.PackageDocker }} PackageVSIX: ${{ parameters.PackageVSIX }} PackagePyPI: ${{ parameters.PackagePyPI }} + PackageMCPB: ${{ parameters.PackageMCPB }} IsTestPipeline: ${{ parameters.IsTestPipeline }} - ${{ elseif eq(parameters.PublishTarget, 'internal') }}: diff --git a/eng/pipelines/templates/jobs/mcpb/pack-and-sign-mcpb.yml b/eng/pipelines/templates/jobs/mcpb/pack-and-sign-mcpb.yml new file mode 100644 index 000000000..b40358405 --- /dev/null +++ b/eng/pipelines/templates/jobs/mcpb/pack-and-sign-mcpb.yml @@ -0,0 +1,129 @@ +# Pipeline template for packaging and signing MCPB bundles via ESRP +# +# This template: +# 1. Downloads signed server binaries and build_info.json +# 2. Packages the binaries into MCP bundles (.mcpb) using the MCPB CLI +# 3. Signs the bundles using ESRP's Pkcs7DetachedSign operation +# 4. Converts the detached .p7s signatures to MCPB embedded format +# 5. Verifies the signatures using mcpb verify +# +# Prerequisites: +# - Signed binaries artifact must exist (binaries_signed) +# - build_info.json must exist with server and platform definitions +# - The server must have a manifest.json in servers/{ServerName}/mcpb/ + +parameters: +- name: DependsOn + type: object + +jobs: +- job: SignAndPackMcpb + displayName: "Sign and Pack MCPB" + dependsOn: ${{ parameters.DependsOn }} + condition: and(succeeded(), ne(variables['NoPackagesChanged'], 'true')) + steps: + - checkout: self + + - download: current + displayName: "Download build_info" + artifact: build_info + + - download: current + displayName: "Download binaries_signed" + artifact: binaries_signed + + # Install .NET SDK 10.0.x (required by global.json for dotnet CLI commands) + - task: UseDotNet@2 + displayName: "Install .NET SDK 10.0.x" + retryCountOnTaskFailure: 3 + inputs: + packageType: sdk + version: 10.0.x + + # Install .NET SDK 8.0.x (required by MCPB CLI which targets .NET 8.0) + - task: UseDotNet@2 + displayName: "Install .NET SDK 8.0.x" + retryCountOnTaskFailure: 3 + inputs: + packageType: sdk + version: 8.0.x + + - pwsh: | + Write-Host "Restoring MCPB CLI from local tool manifest..." + dotnet tool restore + dotnet mcpb --version + displayName: "Restore MCPB CLI" + workingDirectory: $(Build.SourcesDirectory) + + - task: Powershell@2 + displayName: "Pack MCP bundles" + inputs: + pwsh: true + filePath: $(Build.SourcesDirectory)/eng/scripts/Pack-Mcpb.ps1 + arguments: > + -BuildInfoPath '$(Pipeline.Workspace)/build_info/build_info.json' + -ArtifactsPath '$(Pipeline.Workspace)/binaries_signed' + -OutputPath '$(Build.ArtifactStagingDirectory)/mcpb' + + - task: Powershell@2 + displayName: "Stage MCPB files for signing" + inputs: + pwsh: true + filePath: $(Build.SourcesDirectory)/eng/scripts/Stage-McpbForSigning.ps1 + arguments: > + -ArtifactsPath '$(Build.ArtifactStagingDirectory)/mcpb' + -OutputPath '$(Build.ArtifactStagingDirectory)/to_sign' + + # Sign with ESRP using Pkcs7DetachedSign operation + # This produces detached PKCS#7 signatures that we'll embed in the MCPB format + - task: EsrpCodeSigning@6 + displayName: "Sign MCPB - Detached PKCS#7" + inputs: + ConnectedServiceName: 'Azure SDK PME Managed Identity' + UseMSIAuthentication: true + AppRegistrationClientId: '4fa2001b-ad7a-4a1e-9185-d6ea881f8712' + AppRegistrationTenantId: '975f013f-7f24-47e8-a7d3-abc4752bf346' + EsrpClientId: '5e796b8d-3c4d-4e28-93f6-12a44f4368a0' + AuthAKVName: 'kv-azuresdk-codesign' + AuthSignCertName: 'azure-sdk-esrp-signing-certificate' + FolderPath: $(Build.ArtifactStagingDirectory)/to_sign + Pattern: '**/*.signature.p7s' + UseMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "KeyCode": "CP-230012", + "OperationCode": "Pkcs7DetachedSign", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": { + "P7CE": "/p7ce DetachedSignedData", + "P7EKU": "1.2.840.113549.1.7.1", + "FileDigest": "/fd SHA256" + } + } + ] + + - task: Powershell@2 + displayName: "Apply MCPB signatures" + inputs: + pwsh: true + filePath: $(Build.SourcesDirectory)/eng/scripts/Apply-McpbSignatures.ps1 + arguments: > + -ArtifactsPath '$(Build.ArtifactStagingDirectory)/to_sign' + -OutputPath '$(Build.ArtifactStagingDirectory)/signed' + + - task: Powershell@2 + displayName: "Verify MCPB signatures" + inputs: + pwsh: true + filePath: $(Build.SourcesDirectory)/eng/scripts/Verify-McpbSignatures.ps1 + arguments: > + -ArtifactsPath '$(Build.ArtifactStagingDirectory)/signed' + + - template: /eng/common/pipelines/templates/steps/publish-1es-artifact.yml + parameters: + ArtifactPath: $(Build.ArtifactStagingDirectory)/signed + ArtifactName: packages_mcpb_signed + SbomEnabled: ${{ ne(variables['Build.Reason'], 'PullRequest') }} diff --git a/eng/pipelines/templates/jobs/mcpb/release-mcpb.yml b/eng/pipelines/templates/jobs/mcpb/release-mcpb.yml new file mode 100644 index 000000000..8be50ee20 --- /dev/null +++ b/eng/pipelines/templates/jobs/mcpb/release-mcpb.yml @@ -0,0 +1,111 @@ +# Pipeline template for releasing signed MCPB bundles +# +# This template: +# 1. Downloads signed MCPB bundles +# 2. Uploads them to the GitHub Release created by TagRepository job +# +# Prerequisites: +# - packages_mcpb_signed artifact must exist +# - build_info.json must exist with server and version information +# - GitHub Release must already be created (by TagRepository job) + +parameters: +- name: ServerName + type: string +- name: DependsOn + type: object + +jobs: +- deployment: PublishMCPB + displayName: "Publish MCPB to GitHub Release" + dependsOn: ${{ parameters.DependsOn }} + condition: and(succeeded(), ne(variables['Skip.PublishPackage'], 'true')) + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + artifactName: packages_mcpb_signed + targetPath: $(Pipeline.Workspace)/mcpb + - input: pipelineArtifact + artifactName: build_info + targetPath: $(Pipeline.Workspace)/build_info + environment: package-publish + pool: + name: azsdk-pool + image: ubuntu-24.04 + os: linux + strategy: + runOnce: + deploy: + steps: + # Note: checkout is not allowed in release jobs, so we inline the script logic here + - pwsh: | + $ErrorActionPreference = "Stop" + $serverName = '${{ parameters.ServerName }}' + $mcpbPath = '$(Pipeline.Workspace)/mcpb' + $buildInfoPath = '$(Pipeline.Workspace)/build_info/build_info.json' + + Write-Host "Loading build info from $buildInfoPath..." + $buildInfo = Get-Content -Raw -Path $buildInfoPath | ConvertFrom-Json + + $server = $buildInfo.servers | Where-Object { $_.name -eq $serverName } | Select-Object -First 1 + if (-not $server) { + Write-Error "Server '$serverName' not found in build info." + exit 1 + } + + $version = $server.version + $tag = $server.releaseTag + + Write-Host "Server: $serverName" + Write-Host "Version: $version" + Write-Host "Release Tag: $tag" + + # Find MCPB files for this server + $serverMcpbPath = Join-Path $mcpbPath $serverName + if (-not (Test-Path $serverMcpbPath)) { + Write-Error "No MCPB files found for $serverName at $serverMcpbPath" + exit 1 + } + + $mcpbFiles = Get-ChildItem -Path $serverMcpbPath -Filter "*.mcpb" -Recurse + + if ($mcpbFiles.Count -eq 0) { + Write-Error "No .mcpb files found in $serverMcpbPath" + exit 1 + } + + Write-Host "`nUploading $($mcpbFiles.Count) MCPB file(s) to release $tag..." + + $uploadedCount = 0 + $failedCount = 0 + + foreach ($mcpb in $mcpbFiles) { + Write-Host " Uploading: $($mcpb.Name) ($($mcpb.Length) bytes)" + + # Note: --repo is required because checkout is not allowed in release jobs, + # so gh cannot determine the repository from the git context + & gh release upload $tag $mcpb.FullName --repo microsoft/mcp --clobber + + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to upload $($mcpb.Name)" + $failedCount++ + } else { + $uploadedCount++ + } + } + + Write-Host "`nUpload Summary:" + Write-Host " Uploaded: $uploadedCount" + Write-Host " Failed: $failedCount" + + if ($failedCount -gt 0) { + Write-Error "Some MCPB files failed to upload" + exit 1 + } + + Write-Host "`n✓ All MCPB files uploaded successfully" + displayName: "Upload MCPB to GitHub Release" + env: + GH_TOKEN: $(azuresdk-github-pat) diff --git a/eng/pipelines/templates/jobs/release.yml b/eng/pipelines/templates/jobs/release.yml index eac6916d9..2a38ce4f0 100644 --- a/eng/pipelines/templates/jobs/release.yml +++ b/eng/pipelines/templates/jobs/release.yml @@ -9,6 +9,8 @@ parameters: type: boolean - name: PackagePyPI type: boolean +- name: PackageMCPB + type: boolean - name: IsTestPipeline type: boolean @@ -75,6 +77,12 @@ jobs: ServerName: ${{ parameters.ServerName }} DependsOn: TagRepository +- ${{ if parameters.PackageMCPB }}: + - template: /eng/pipelines/templates/jobs/mcpb/release-mcpb.yml + parameters: + ServerName: ${{ parameters.ServerName }} + DependsOn: TagRepository + - ${{ if parameters.PackageDocker }}: - template: /eng/pipelines/templates/jobs/docker/release-docker.yml parameters: @@ -86,6 +94,7 @@ jobs: - template: /eng/pipelines/templates/jobs/update-mcp-repository.yml parameters: ServerName: ${{ parameters.ServerName }} + PackageMCPB: ${{ parameters.PackageMCPB }} DependsOn: - PublishNugetPackage - PublishNpm @@ -93,6 +102,8 @@ jobs: - PublishACR - ${{ if parameters.PackagePyPI }}: - PublishPyPI + - ${{ if parameters.PackageMCPB }}: + - PublishMCPB - job: UpdatePackageVersion displayName: "Update Package Versions" diff --git a/eng/pipelines/templates/jobs/sign-and-pack.yml b/eng/pipelines/templates/jobs/sign-and-pack.yml index 90295acdb..8f6f0a374 100644 --- a/eng/pipelines/templates/jobs/sign-and-pack.yml +++ b/eng/pipelines/templates/jobs/sign-and-pack.yml @@ -7,6 +7,8 @@ parameters: type: boolean - name: PackagePyPI type: boolean +- name: PackageMCPB + type: boolean jobs: - job: Sign @@ -126,6 +128,11 @@ jobs: parameters: DependsOn: Sign +- ${{ if parameters.PackageMCPB }}: + - template: /eng/pipelines/templates/jobs/mcpb/pack-and-sign-mcpb.yml + parameters: + DependsOn: Sign + - ${{ if parameters.PackageDocker }}: - template: /eng/pipelines/templates/jobs/docker/stage-docker.yml parameters: diff --git a/eng/pipelines/templates/jobs/update-mcp-repository.yml b/eng/pipelines/templates/jobs/update-mcp-repository.yml index 42b00e9ca..96430e0ff 100644 --- a/eng/pipelines/templates/jobs/update-mcp-repository.yml +++ b/eng/pipelines/templates/jobs/update-mcp-repository.yml @@ -6,6 +6,8 @@ parameters: default: 'https://api.nuget.org/v3/index.json' - name: DependsOn type: object +- name: PackageMCPB + type: boolean jobs: - job: BuildAndUpdateMcpRepository @@ -25,6 +27,22 @@ jobs: displayName: Download build_info artifact: build_info + - ${{ if parameters.PackageMCPB }}: + - download: current + displayName: Download signed MCPB packages + artifact: packages_mcpb_signed + + - task: Powershell@2 + displayName: 'Update server.json with MCPB SHA256 hashes' + inputs: + pwsh: true + filePath: $(Build.SourcesDirectory)/eng/scripts/Update-ServerJsonMcpbHashes.ps1 + arguments: > + -ServerName '${{ parameters.ServerName }}' + -McpbPath '$(Pipeline.Workspace)/packages_mcpb_signed/${{ parameters.ServerName }}' + -ServerJsonPath '$(Pipeline.Workspace)/build_info/${{ parameters.ServerName }}/server.json' + -BuildInfoPath '$(Pipeline.Workspace)/build_info/build_info.json' + - task: Powershell@2 displayName: 'Verify Nuget package was published' inputs: diff --git a/eng/scripts/Apply-McpbSignatures.ps1 b/eng/scripts/Apply-McpbSignatures.ps1 new file mode 100644 index 000000000..149fd1b2a --- /dev/null +++ b/eng/scripts/Apply-McpbSignatures.ps1 @@ -0,0 +1,212 @@ +#!/bin/env pwsh +#Requires -Version 7 + +<# +.SYNOPSIS + Applies detached PKCS#7 signatures to MCPB files. + +.DESCRIPTION + This script takes MCPB files and their corresponding .signature.p7s files + (produced by ESRP's Pkcs7DetachedSign operation) and combines them into + signedFile MCPB files using the MCPB signature format. + + The MCPB signature format (per https://github.com/modelcontextprotocol/mcpb/blob/main/CLI.md): + + [Original MCPB ZIP content] + MCPB_SIG_V1 + [4-byte little-endian length prefix] + [DER-encoded PKCS#7 signature] + MCPB_SIG_END + +.PARAMETER ArtifactsPath + Path to the directory containing .mcpb files and their .signature.p7s files. + +.PARAMETER OutputPath + Path to the output directory for signedFile MCPB files. + +.EXAMPLE + ./Apply-McpbSignatures.ps1 -ArtifactsPath "./to_sign" -OutputPath "./signedFile" +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string] $ArtifactsPath, + + [Parameter(Mandatory = $false)] + [string] $OutputPath +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/../common/scripts/common.ps1" + +# MCPB signature markers (ASCII) +$SIG_V1_MARKER = "MCPB_SIG_V1" +$SIG_END_MARKER = "MCPB_SIG_END" + +# Maximum signature block size, must match the value in Stage-McpbForSigning.ps1. +# The EOCD comment length is pre-set to this value before ESRP signing, so the +# signature block must be padded to exactly this size for the ZIP to be valid. +$MAX_SIG_BLOCK_SIZE = 16384 + +<# +.SYNOPSIS + Converts a PKCS#7 detached signature to MCPB embedded signature format. +#> +function Convert-P7sToMcpbSignature { + param( + [Parameter(Mandatory)] + [string] $P7sFile, + + [Parameter(Mandatory)] + [string] $McpbFile, + + [Parameter(Mandatory)] + [string] $OutputFile + ) + + # Validate inputs + if (-not (Test-Path $P7sFile)) { + throw "Signature file not found: $P7sFile" + } + + if (-not (Test-Path $McpbFile)) { + throw "MCPB file not found: $McpbFile" + } + + # Read signature bytes + $signatureBytes = [System.IO.File]::ReadAllBytes($P7sFile) + + # Create length prefix (4-byte little-endian) + $lengthBytes = [BitConverter]::GetBytes([uint32]$signatureBytes.Length) + + # Create markers as byte arrays + $sigV1MarkerBytes = [System.Text.Encoding]::ASCII.GetBytes($SIG_V1_MARKER) + $sigEndMarkerBytes = [System.Text.Encoding]::ASCII.GetBytes($SIG_END_MARKER) + + # Read original MCPB content + $mcpbContent = [System.IO.File]::ReadAllBytes($McpbFile) + + # Check if the MCPB is already signedFile by looking for the MCPB_SIG_END marker at the + # end of the file. This is always the last bytes of a signedFile MCPB, so we only need + # to read a small fixed-size tail rather than scanning the entire binary. + if ($mcpbContent.Length -ge $sigEndMarkerBytes.Length) { + $tailStart = $mcpbContent.Length - $sigEndMarkerBytes.Length + $tailString = [System.Text.Encoding]::ASCII.GetString($mcpbContent, $tailStart, $sigEndMarkerBytes.Length) + if ($tailString -eq $SIG_END_MARKER) { + throw "MCPB file appears to already be signedFile. Use 'mcpb unsign' to remove existing signature first." + } + } + + # Calculate the unpadded signature block size: + # MCPB_SIG_V1 (11) + length (4) + signature (N) + MCPB_SIG_END (12) = N + 27 + $unpaddedBlockSize = $sigV1MarkerBytes.Length + $lengthBytes.Length + $signatureBytes.Length + $sigEndMarkerBytes.Length + + if ($unpaddedBlockSize -gt $MAX_SIG_BLOCK_SIZE) { + throw "Signature block ($unpaddedBlockSize bytes) exceeds MAX_SIG_BLOCK_SIZE ($MAX_SIG_BLOCK_SIZE bytes). Increase MAX_SIG_BLOCK_SIZE in both Stage-McpbForSigning.ps1 and Apply-McpbSignatures.ps1." + } + + # Pad with zeros so the total signature block equals MAX_SIG_BLOCK_SIZE. + # This makes the ZIP valid because EOCD comment_length == MAX_SIG_BLOCK_SIZE. + # The padding sits between the DER signature and MCPB_SIG_END. The mcpb + # extraction logic reads exactly sigLength bytes via the 4-byte length prefix + # and ignores the padding. + $paddingSize = $MAX_SIG_BLOCK_SIZE - $unpaddedBlockSize + $paddingBytes = [byte[]]::new($paddingSize) + + # Combine: MCPB + MCPB_SIG_V1 + length + signature + padding + MCPB_SIG_END + $signedContent = [System.Collections.Generic.List[byte]]::new() + + $signedContent.AddRange($mcpbContent) + $signedContent.AddRange($sigV1MarkerBytes) + $signedContent.AddRange($lengthBytes) + $signedContent.AddRange($signatureBytes) + $signedContent.AddRange($paddingBytes) + $signedContent.AddRange($sigEndMarkerBytes) + + [System.IO.File]::WriteAllBytes($OutputFile, $signedContent.ToArray()) +} + +# Validate required parameters when running as script +if (-not $ArtifactsPath) { + LogError "ArtifactsPath is required" + exit 1 +} +if (-not $OutputPath) { + LogError "OutputPath is required" + exit 1 +} + +# Main script logic +if (!(Test-Path $ArtifactsPath)) { + LogError "Artifacts directory not found: $ArtifactsPath" + exit 1 +} + +New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null + +LogInfo "Applying signatures to MCPB files..." + +# Find all .mcpb files +$mcpbFiles = Get-ChildItem -Path $ArtifactsPath -Filter "*.mcpb" -Recurse + +if ($mcpbFiles.Count -eq 0) { + LogError "No .mcpb files found in $ArtifactsPath" + exit 1 +} + +$signedFiles = [System.Collections.Generic.List[string]]::new() +$failedFiles = [System.Collections.Generic.List[string]]::new() + +foreach ($mcpb in $mcpbFiles) { + $sigFile = Join-Path $mcpb.Directory.FullName ($mcpb.BaseName + ".signature.p7s") + + if (-not (Test-Path $sigFile)) { + LogWarning "No signature file found for $($mcpb.Name)" + $failedFiles.Add($mcpb.Name) + continue + } + + # Preserve directory structure in output + $pathativePath = $mcpb.Directory.FullName.Substring((Resolve-Path $ArtifactsPath).Path.Length).TrimStart('\', '/') + $targetDir = Join-Path $OutputPath $pathativePath + + if (-not (Test-Path $targetDir)) { + New-Item -ItemType Directory -Path $targetDir -Force | Out-Null + } + + $outputFile = Join-Path $targetDir $mcpb.Name + + LogInfo " Signing: $($mcpb.Name)" + + try { + Convert-P7sToMcpbSignature -P7sFile $sigFile -McpbFile $mcpb.FullName -OutputFile $outputFile + } + catch { + LogError "Failed to sign $($mcpb.Name): $_" + $failedFiles.Add($mcpb.Name) + continue + } + + if (-not (Test-Path $outputFile)) { + LogError "Failed to create signedFile MCPB: $outputFile" + $failedFiles.Add($mcpb.Name) + continue + } + + $signedFiles.Add($outputFile) +} + +LogInfo "`nSigned MCPB files:" +foreach ($signedFile in $signedFiles) { + $fileInfo = Get-Item $signedFile + $relativePath = $fileInfo.FullName.Substring((Resolve-Path $OutputPath).Path.Length).TrimStart('\', '/') + LogInfo " $relativePath ($($fileInfo.Length) bytes)" +} + +LogInfo "`nSigning complete: $($signedFiles.Count) succeeded, $($failedFiles.Count) failed" + +if ($failedFiles.Count -gt 0) { + LogError "Some MCPB files failed to sign" + exit 1 +} diff --git a/eng/scripts/Get-ProjectProperties.ps1 b/eng/scripts/Get-ProjectProperties.ps1 index 5108b0672..09068631a 100644 --- a/eng/scripts/Get-ProjectProperties.ps1 +++ b/eng/scripts/Get-ProjectProperties.ps1 @@ -60,7 +60,9 @@ $propertyList = @( 'IsAotCompatible', - 'McpRepositoryName' + 'McpRepositoryName', + + 'McpbPlatforms' ) $projectFile = $projectFiles | Select-Object -First 1 diff --git a/eng/scripts/New-BuildInfo.ps1 b/eng/scripts/New-BuildInfo.ps1 index 6e8a4187a..c5194dc56 100644 --- a/eng/scripts/New-BuildInfo.ps1 +++ b/eng/scripts/New-BuildInfo.ps1 @@ -531,6 +531,7 @@ function Get-ServerDetails { pypiPackageKeywords = @($props.PypiPackageKeywords -split '[;,] *' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) platforms = $platforms mcpRepositoryName = $props.McpRepositoryName + mcpbPlatforms = @($props.McpbPlatforms -split '[;,] *' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) serverJsonPath = $props.ServerJsonPath | Get-RepoRelativePath -NormalizeSeparators } } diff --git a/eng/scripts/New-ServerJson.ps1 b/eng/scripts/New-ServerJson.ps1 index 11f737b8d..0c3976800 100644 --- a/eng/scripts/New-ServerJson.ps1 +++ b/eng/scripts/New-ServerJson.ps1 @@ -64,7 +64,9 @@ ReplacePropertyValue -HashTable $jsonHashTable -PropertyName 'name' -NewValue $s ReplacePropertyValue -HashTable $jsonHashTable -PropertyName 'version' -NewValue $server.version foreach ($package in $jsonHashTable.packages) { - ReplacePropertyValue -HashTable $package -PropertyName 'version' -NewValue $server.version + if ($package.ContainsKey('version')) { + ReplacePropertyValue -HashTable $package -PropertyName 'version' -NewValue $server.version + } switch ($package.registryType) { 'nuget' { @@ -85,6 +87,22 @@ foreach ($package in $jsonHashTable.packages) { } } +# Generate MCPB package entries from the McpbPlatforms csproj property +if ($server.mcpbPlatforms -and $server.mcpbPlatforms.Count -gt 0) { + foreach ($mcpbPlatform in $server.mcpbPlatforms) { + $mcpbFilename = "$($server.name)-$mcpbPlatform.mcpb" + $mcpbUrl = "https://github.com/microsoft/mcp/releases/download/$($server.releaseTag)/$mcpbFilename" + + $jsonHashTable.packages += @{ + registryType = 'mcpb' + identifier = $mcpbUrl + # fileSha256 will be set by Update-ServerJsonMcpbHashes.ps1 after signing + fileSha256 = '' + transport = @{ type = 'stdio' } + } + } +} + $updatedJson = $jsonHashTable | ConvertTo-Json -Depth 10 if (!$OutputPath) { diff --git a/eng/scripts/Pack-Mcpb.ps1 b/eng/scripts/Pack-Mcpb.ps1 new file mode 100644 index 000000000..d8d60c688 --- /dev/null +++ b/eng/scripts/Pack-Mcpb.ps1 @@ -0,0 +1,253 @@ +#!/bin/env pwsh +#Requires -Version 7 + +<# +.SYNOPSIS + Creates MCPB (MCP Bundle) packages from trimmed server binaries. + +.DESCRIPTION + This script packages MCP servers into the MCPB format using the official MCPB CLI tool. + It reads server and platform information from build_info.json and creates unsigned .mcpb + files for each platform. The packages can then be signed using ESRP in a separate step. + +.PARAMETER ArtifactsPath + Path to the build artifacts directory containing the trimmed server binaries. + Defaults to ".work/build" in the repo root. + +.PARAMETER BuildInfoPath + Path to the build_info.json file containing server and platform details. + Defaults to ".work/build_info.json" in the repo root. + +.PARAMETER OutputPath + Output directory for the .mcpb files. + Defaults to ".work/packages_mcpb" in the repo root. + +.PARAMETER KeepStagingDirectory + If specified, keeps the staging directory after packaging for debugging purposes. + The staging directory is located at ".work/temp_mcpb" in the repo root. + +.EXAMPLE + ./Pack-Mcpb.ps1 + +.EXAMPLE + ./Pack-Mcpb.ps1 -ArtifactsPath ".work/build" -BuildInfoPath ".work/build_info.json" -OutputPath ".work/packages_mcpb" +#> + +[CmdletBinding()] +param( + [string] $ArtifactsPath, + [string] $BuildInfoPath, + [string] $OutputPath, + [switch] $KeepStagingDirectory +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/../common/scripts/common.ps1" + +$RepoRoot = $RepoRoot.Path.Replace('\', '/') +$ignoreMissingArtifacts = $env:TF_BUILD -ne 'true' +$exitCode = 0 + +if (!$ArtifactsPath) { + $ArtifactsPath = "$RepoRoot/.work/build" +} + +if (!$BuildInfoPath) { + $BuildInfoPath = "$RepoRoot/.work/build_info.json" +} + +if (!$OutputPath) { + $OutputPath = "$RepoRoot/.work/packages_mcpb" +} + +if (!(Test-Path $ArtifactsPath)) { + LogError "Artifacts path $ArtifactsPath does not exist." + $exitCode = 1 +} + +if (!(Test-Path $BuildInfoPath)) { + LogError "Build info file $BuildInfoPath does not exist. Run eng/scripts/New-BuildInfo.ps1 to create it." + $exitCode = 1 +} + +if ($exitCode -ne 0) { + exit $exitCode +} + +# Restore MCPB CLI from local tool manifest (.config/dotnet-tools.json) +LogInfo "Restoring MCPB CLI..." +Invoke-LoggedCommand "dotnet tool restore" -GroupOutput + +$buildInfo = Get-Content $BuildInfoPath -Raw | ConvertFrom-Json + +if (Test-Path $OutputPath) { + LogInfo "Cleaning existing output path $OutputPath" + Remove-Item -Path $OutputPath -Recurse -Force -ErrorAction SilentlyContinue +} + +New-Item -ItemType Directory -Force -Path $OutputPath | Out-Null + +$tempPath = "$RepoRoot/.work/temp_mcpb" + +foreach ($server in $buildInfo.servers) { + $mcpbDirectory = "$RepoRoot/servers/$($server.name)/mcpb" + $manifestPath = "$mcpbDirectory/manifest.json" + + if (!(Test-Path $manifestPath)) { + LogWarning "MCPB manifest not found at $manifestPath. Skipping server $($server.name)." + continue + } + + LogInfo "`n========================================" + LogInfo "Packing MCPB for server: $($server.name)" + LogInfo "Version: $($server.version)" + LogInfo "========================================`n" + + # Filter platforms: only trimmed, non-native, non-special-purpose platforms + $filteredPlatforms = $server.platforms | Where-Object { + $_.trimmed -eq $true -and + -not $_.native -and + -not $_.specialPurpose + } + + if ($filteredPlatforms.Count -eq 0) { + LogInfo "No suitable platforms found for MCPB packaging for server $($server.name)" + continue + } + + foreach ($platform in $filteredPlatforms) { + $platformDirectory = "$ArtifactsPath/$($platform.artifactPath)" + $platformName = $platform.name + + if (!(Test-Path $platformDirectory)) { + $message = "Platform directory $platformDirectory does not exist." + if ($ignoreMissingArtifacts) { + LogWarning $message + continue + } else { + LogError $message + $exitCode = 1 + continue + } + } + + LogInfo @" + +------------------------------------------------------------------------------------ +Processing MCPB packaging: + Server: $($server.name) + Platform: $platformName + Version: $($server.version) + CLI Name: $($server.cliName) + Binaries Path: $platformDirectory +------------------------------------------------------------------------------------ + +"@ + + # Create clean staging directory for this platform + $stagingDir = "$tempPath/$($server.name)/$platformName" + Remove-Item -Path $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null + New-Item -ItemType Directory -Force -Path "$stagingDir/server" | Out-Null + + # Copy trimmed binaries to server/ subdirectory + LogInfo "Copying trimmed binaries from $platformDirectory..." + Copy-Item -Path "$platformDirectory/*" -Destination "$stagingDir/server" -Recurse + + # Copy and update manifest.json for this platform + # The source manifest uses platform-agnostic paths (no extension), so we need to add + # the correct extension for this platform's executable + LogInfo "Copying manifest from $manifestPath..." + $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json + + $executableName = $server.cliName + $platform.extension + + # Update version from build_info.json (source of truth for all packaging) + $manifest.version = $server.version + + # entry_point always uses forward slashes (required by MCPB CLI validation) + $manifest.server.entry_point = "server/$executableName" + + # command uses backslashes for Windows paths, forward slashes for all other platforms + $pathSep = if ($platform.operatingSystem -eq 'windows') { '\' } else { '/' } + $manifest.server.mcp_config.command = "`${__dirname}${pathSep}server${pathSep}$executableName" + + # Remove platform_overrides since this bundle is platform-specific + if ($manifest.server.mcp_config.PSObject.Properties.Name -contains 'platform_overrides') { + $manifest.server.mcp_config.PSObject.Properties.Remove('platform_overrides') + } + + $manifest | ConvertTo-Json -Depth 100 | Set-Content "$stagingDir/manifest.json" -NoNewline + + # Copy and rename icon to servericon.png (required name for MCPB bundles) + $packageIconPath = "$RepoRoot/$($server.packageIcon)" + if (Test-Path $packageIconPath) { + LogInfo "Copying icon from $packageIconPath..." + Copy-Item -Path $packageIconPath -Destination "$stagingDir/servericon.png" -Force + } else { + LogWarning "No icon found for $($server.name). MCPB may not validate." + } + + # Copy LICENSE and NOTICE.txt + Copy-Item -Path "$RepoRoot/LICENSE" -Destination $stagingDir -Force + Copy-Item -Path "$RepoRoot/NOTICE.txt" -Destination $stagingDir -Force + + # Validate manifest + LogInfo "Validating manifest..." + $validateResult = dotnet mcpb validate $stagingDir 2>&1 + if ($LASTEXITCODE -ne 0) { + LogError "Manifest validation failed for $($server.name) on $platformName" + LogError $validateResult + $exitCode = 1 + continue + } + LogInfo "Manifest validation passed" + + # Create output directory for this server/platform + $serverOutputPath = "$OutputPath/$($server.artifactPath)" + New-Item -ItemType Directory -Force -Path $serverOutputPath | Out-Null + + # Pack the MCPB + # Use --update to auto-update manifest with discovered tools from the server + $mcpbFileName = "$($server.name)-$($platform.dotnetOs)-$($platform.architecture).mcpb" + $mcpbFilePath = "$serverOutputPath/$mcpbFileName" + + LogInfo "Packing MCPB to $mcpbFilePath..." + & dotnet mcpb pack $stagingDir $mcpbFilePath --update + if ($LASTEXITCODE -ne 0) { + LogError "MCPB packing failed for $($server.name) on $platformName" + $exitCode = 1 + continue + } + + # Get file size for reporting + $fileSize = (Get-Item $mcpbFilePath).Length + $fileSizeMB = [math]::Round($fileSize / 1MB, 2) + + LogInfo "Created: $mcpbFilePath ($fileSizeMB MB)" + + # Show package info + LogInfo "`nPackage info:" + dotnet mcpb info $mcpbFilePath + } +} + +# Cleanup temp directory (unless -KeepStagingDirectory is specified) +if ($KeepStagingDirectory) { + LogWarning "`nStaging directory preserved at: $tempPath" +} else { + Remove-Item -Path $tempPath -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($exitCode -eq 0) { + LogInfo "`n========================================" + LogInfo "MCPB packaging completed successfully!" + LogInfo "Output: $OutputPath" + LogInfo "========================================`n" +} else { + LogError "`n========================================" + LogError "MCPB packaging completed with errors." + LogError "========================================`n" +} + +exit $exitCode diff --git a/eng/scripts/Stage-McpbForSigning.ps1 b/eng/scripts/Stage-McpbForSigning.ps1 new file mode 100644 index 000000000..767cc9a86 --- /dev/null +++ b/eng/scripts/Stage-McpbForSigning.ps1 @@ -0,0 +1,141 @@ +#!/bin/env pwsh +#Requires -Version 7 + +<# +.SYNOPSIS + Stages MCPB files for ESRP detached signing. + +.DESCRIPTION + This script prepares MCPB files for ESRP's Pkcs7DetachedSign operation. + ESRP replaces the input file content with the signature, so we: + 1. Copy each .mcpb file to the staging directory + 2. Create a .signature.p7s copy for ESRP to process + + After ESRP signing, the .signature.p7s files will contain the detached signatures. + +.PARAMETER ArtifactsPath + Path to the directory containing unsigned MCPB files. + +.PARAMETER OutputPath + Path to the staging directory for ESRP signing. + +.EXAMPLE + ./Stage-McpbForSigning.ps1 -ArtifactsPath "./mcpb" -OutputPath "./to_sign" +#> +param( + [Parameter(Mandatory = $true)] + [string] $ArtifactsPath, + + [Parameter(Mandatory = $true)] + [string] $OutputPath +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/../common/scripts/common.ps1" + +# Maximum signature block size in bytes. The ZIP EOCD comment length is set to this +# value before signing so that the final signed file remains a valid ZIP archive. +# The signature block (MCPB_SIG_V1 + length + signature + padding + MCPB_SIG_END) +# is padded with zeros to exactly this size. +# Current ESRP signatures are ~4KB; 16384 provides ample headroom. +$MAX_SIG_BLOCK_SIZE = 16384 + +<# +.SYNOPSIS + Updates the ZIP End of Central Directory (EOCD) comment length field. + +.DESCRIPTION + Sets the EOCD comment length to a fixed value so that after the MCPB signature + block is appended (and padded to this exact size), the file remains a valid ZIP. + This is necessary because some ZIP parsers (e.g., Claude Desktop) strictly + validate that file_size == EOCD_offset + 22 + comment_length. +#> +function Set-ZipEocdCommentLength { + param( + [Parameter(Mandatory)] + [string] $FilePath, + + [Parameter(Mandatory)] + [uint16] $CommentLength + ) + + $bytes = [System.IO.File]::ReadAllBytes($FilePath) + $len = $bytes.Length + + # EOCD signature: 0x06054b50 (PK\x05\x06) + $eocdSig = @([byte]0x50, [byte]0x4B, [byte]0x05, [byte]0x06) + $eocdOffset = -1 + + # Search backwards from end of file (EOCD is at most 65557 bytes from end) + $searchStart = [Math]::Max(0, $len - 65557) + for ($i = $len - 22; $i -ge $searchStart; $i--) { + if ($bytes[$i] -eq $eocdSig[0] -and $bytes[$i+1] -eq $eocdSig[1] -and + $bytes[$i+2] -eq $eocdSig[2] -and $bytes[$i+3] -eq $eocdSig[3]) { + $eocdOffset = $i + break + } + } + + if ($eocdOffset -lt 0) { + throw "ZIP EOCD signature not found in $FilePath" + } + + # EOCD comment length is at offset 20-21 (2 bytes, little-endian) + $commentLenBytes = [BitConverter]::GetBytes($CommentLength) + $bytes[$eocdOffset + 20] = $commentLenBytes[0] + $bytes[$eocdOffset + 21] = $commentLenBytes[1] + + [System.IO.File]::WriteAllBytes($FilePath, $bytes) +} + +if (!(Test-Path $ArtifactsPath)) { + LogError "MCPB directory not found: $ArtifactsPath" + exit 1 +} + +New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null + +LogInfo "Staging MCPB files for signing..." + +# Find all .mcpb files recursively (they're organized by server name) +$mcpbFiles = Get-ChildItem -Path $ArtifactsPath -Filter "*.mcpb" -Recurse + +if ($mcpbFiles.Count -eq 0) { + LogError "No .mcpb files found in $ArtifactsPath" + exit 1 +} + +foreach ($mcpb in $mcpbFiles) { + # Preserve directory structure + $relativePath = $mcpb.Directory.FullName.Substring((Resolve-Path $ArtifactsPath).Path.Length).TrimStart('\', '/') + $targetDir = Join-Path $OutputPath $relativePath + + if (-not (Test-Path $targetDir)) { + New-Item -ItemType Directory -Path $targetDir -Force | Out-Null + } + + # Copy original .mcpb and update EOCD comment length. + # The comment length is set to MAX_SIG_BLOCK_SIZE so that after the signature + # block is appended (padded to this size), the file is a valid ZIP. ESRP signs + # this modified content, so mcpb verify still works because the "original content" + # extracted during verification matches what was signed. + $mcpbDest = Join-Path $targetDir $mcpb.Name + Copy-Item $mcpb.FullName $mcpbDest -Force + Set-ZipEocdCommentLength -FilePath $mcpbDest -CommentLength $MAX_SIG_BLOCK_SIZE + LogInfo " Updated EOCD comment length to $MAX_SIG_BLOCK_SIZE for $($mcpb.Name)" + + # Create .signature.p7s copy for ESRP to sign (same modified content) + $sigName = $mcpb.BaseName + ".signature.p7s" + $sigDest = Join-Path $targetDir $sigName + Copy-Item $mcpbDest $sigDest -Force + + LogInfo " Staged: $($mcpb.Name) -> $sigName" +} + +LogInfo "`nFiles staged for signing:" +Get-ChildItem -Path $OutputPath -Recurse -File | ForEach-Object { + $rel = $_.FullName.Substring((Resolve-Path $OutputPath).Path.Length).TrimStart('\', '/') + LogInfo " $rel ($($_.Length) bytes)" +} + +LogInfo "`nStaged $($mcpbFiles.Count) MCPB file(s) for signing" diff --git a/eng/scripts/Update-ServerJsonMcpbHashes.ps1 b/eng/scripts/Update-ServerJsonMcpbHashes.ps1 new file mode 100644 index 000000000..3e618bbdb --- /dev/null +++ b/eng/scripts/Update-ServerJsonMcpbHashes.ps1 @@ -0,0 +1,134 @@ +#!/bin/env pwsh +#Requires -Version 7 + +<# +.SYNOPSIS + Updates the server.json file with SHA256 hashes for signed MCPB packages. + +.DESCRIPTION + This script computes SHA256 hashes for signed MCPB files and updates the + corresponding fileSha256 fields in server.json. This is required for + publishing to the MCP Registry, which validates file integrity. + +.PARAMETER ServerName + Name of the server (e.g., Azure.Mcp.Server). + +.PARAMETER McpbPath + Path to the directory containing signed MCPB files. + +.PARAMETER ServerJsonPath + Path to the server.json file to update. + +.PARAMETER OutputPath + Optional path for the updated server.json. Defaults to updating in place. + +.PARAMETER BuildInfoPath + Path to the build_info.json file containing server and platform details. + +.EXAMPLE + ./Update-ServerJsonMcpbHashes.ps1 -ServerName "Azure.Mcp.Server" ` + -McpbPath "./packages_mcpb_signed/Azure.Mcp.Server" ` + -ServerJsonPath "./build_info/Azure.Mcp.Server/server.json" ` + -BuildInfoPath "./build_info/build_info.json" +#> +param( + [Parameter(Mandatory = $true)] + [string] $ServerName, + + [Parameter(Mandatory = $true)] + [string] $McpbPath, + + [Parameter(Mandatory = $true)] + [string] $ServerJsonPath, + + [Parameter(Mandatory = $true)] + [string] $BuildInfoPath, + + [string] $OutputPath +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/../common/scripts/common.ps1" + +if (!(Test-Path $ServerJsonPath)) { + LogError "Server JSON file not found: $ServerJsonPath" + exit 1 +} + +if (!(Test-Path $McpbPath)) { + LogError "MCPB directory not found: $McpbPath" + exit 1 +} + +if (!(Test-Path $BuildInfoPath)) { + LogError "Build info file not found: $BuildInfoPath" + exit 1 +} + +# Load platform data from build info +$buildInfo = Get-Content $BuildInfoPath -Raw | ConvertFrom-Json +$server = $buildInfo.servers | Where-Object { $_.name -ieq $ServerName } + +if (!$server) { + LogError "Server '$ServerName' not found in build info file $BuildInfoPath." + exit 1 +} + +# Find all MCPB files and compute their SHA256 hashes +$mcpbFiles = Get-ChildItem -Path $McpbPath -Filter "*.mcpb" -Recurse +$hashes = @{} + +foreach ($mcpb in $mcpbFiles) { + # Extract platform from filename (e.g., "Azure.Mcp.Server-win-x64.mcpb" -> "win-x64") + if ($mcpb.BaseName -match "^$([regex]::Escape($ServerName))-(.+)$") { + $platform = $Matches[1] + + # Compute SHA256 hash + $hash = (Get-FileHash -Path $mcpb.FullName -Algorithm SHA256).Hash.ToLower() + $hashes[$platform] = $hash + + LogInfo " $($mcpb.Name): $hash" + } else { + LogWarning "Could not extract platform from MCPB filename: $($mcpb.Name)" + } +} + +if ($hashes.Count -eq 0) { + LogError "No MCPB files found in $McpbPath" + exit 1 +} + +# Read and update server.json +$serverJson = Get-Content -Path $ServerJsonPath -Raw | ConvertFrom-Json -AsHashtable + +$updatedCount = 0 +foreach ($package in $serverJson.packages) { + if ($package.registryType -eq 'mcpb') { + # Find the platform from the identifier URL (e.g., "-win-x64.mcpb") + foreach ($mcpbPlatform in $server.mcpbPlatforms) { + if ($package.identifier -like "*-$mcpbPlatform.mcpb*") { + if ($hashes.ContainsKey($mcpbPlatform)) { + $package.fileSha256 = $hashes[$mcpbPlatform] + $updatedCount++ + LogInfo "Updated SHA256 for $mcpbPlatform package" + } else { + LogWarning "No hash found for platform $mcpbPlatform" + } + break + } + } + } +} + +if ($updatedCount -eq 0) { + LogWarning "No MCPB package hashes were updated in server.json" +} + +# Write updated server.json +$outputFile = if ($OutputPath) { $OutputPath } else { $ServerJsonPath } +$updatedJson = $serverJson | ConvertTo-Json -Depth 10 + +New-Item -ItemType File -Force -Path $outputFile | Out-Null +Set-Content -Path $outputFile -Value $updatedJson -Encoding UTF8 -NoNewLine + +LogInfo "Updated server.json written to $outputFile ($updatedCount MCPB hashes updated)" diff --git a/eng/scripts/Verify-McpbSignatures.ps1 b/eng/scripts/Verify-McpbSignatures.ps1 new file mode 100644 index 000000000..18ce57673 --- /dev/null +++ b/eng/scripts/Verify-McpbSignatures.ps1 @@ -0,0 +1,108 @@ +#!/bin/env pwsh +#Requires -Version 7 + +<# +.SYNOPSIS + Verifies signatures on MCPB files. + +.DESCRIPTION + This script verifies the signatures on all MCPB files in a directory + using the mcpb CLI tool. It provides detailed output about each file + and summarizes the verification results. + +.PARAMETER ArtifactsPath + Path to the directory containing signed MCPB files. + +.PARAMETER FailOnError + If set, the script will exit with an error code if any verification fails, + including trust chain warnings. Without this flag, the script will still fail + on genuinely invalid signatures but will tolerate trust chain issues (expected + for Microsoft-signed packages when the certificate chain is not in the trust store). + +.EXAMPLE + ./Verify-McpbSignatures.ps1 -ArtifactsPath "./signed" + +.EXAMPLE + ./Verify-McpbSignatures.ps1 -ArtifactsPath "./signed" -FailOnError +#> +param( + [Parameter(Mandatory = $true)] + [string] $ArtifactsPath, + + [switch] $FailOnError +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/../common/scripts/common.ps1" + +if (!(Test-Path $ArtifactsPath)) { + LogError "MCPB directory not found: $ArtifactsPath" + exit 1 +} + +# Restore MCPB CLI from local tool manifest (.config/dotnet-tools.json) +LogInfo "Restoring MCPB CLI..." +Invoke-LoggedCommand "dotnet tool restore" -GroupOutput + +LogInfo "Verifying signed MCPB files..." + +$mcpbFiles = Get-ChildItem -Path $ArtifactsPath -Filter "*.mcpb" -Recurse + +if ($mcpbFiles.Count -eq 0) { + LogError "No .mcpb files found in $ArtifactsPath" + exit 1 +} + +$passedCount = 0 +$warningCount = 0 +$failedCount = 0 + +foreach ($mcpb in $mcpbFiles) { + LogInfo "`n=== Verifying: $($mcpb.Name) ===" + + # Show bundle info + & dotnet mcpb info $mcpb.FullName + + # Verify signature and capture output for classification + $verifyOutput = & dotnet mcpb verify $mcpb.FullName 2>&1 | Out-String + $verifyExitCode = $LASTEXITCODE + + LogInfo $verifyOutput + + if ($verifyExitCode -eq 0) { + LogInfo "✓ $($mcpb.Name) - Signature verified" + $passedCount++ + } else { + # Distinguish untrusted certificate chain (expected in CI for Microsoft-signed + # packages) from genuinely invalid or corrupt signatures. + $isTrustChainIssue = $verifyOutput -match '(?i)(untrusted|chain|trust|certificate.*not found|root.*not.*trusted|certificate.*expired)' + + if ($isTrustChainIssue) { + LogWarning "✗ $($mcpb.Name) - Certificate chain not trusted (exit code $verifyExitCode). This is expected for Microsoft-signed packages in CI." + $warningCount++ + } else { + LogError "✗ $($mcpb.Name) - Signature verification failed (exit code $verifyExitCode)" + $failedCount++ + } + } +} + +LogInfo "`n=== Verification Summary ===" +LogInfo " Passed: $passedCount" +LogInfo " Warnings (trust chain): $warningCount" +LogInfo " Failed (invalid signature): $failedCount" +LogInfo " Total: $($mcpbFiles.Count)" + +# Always fail on genuinely invalid signatures regardless of -FailOnError +if ($failedCount -gt 0) { + LogError "Some MCPB files have invalid signatures" + exit 1 +} + +# Trust chain warnings are only fatal when -FailOnError is set +if ($FailOnError -and $warningCount -gt 0) { + LogError "Some MCPB files had trust chain warnings and -FailOnError is set" + exit 1 +} + +LogInfo "`nVerification complete" diff --git a/servers/Azure.Mcp.Server/build.yml b/servers/Azure.Mcp.Server/build.yml index 1c7d90b87..8afd16a59 100644 --- a/servers/Azure.Mcp.Server/build.yml +++ b/servers/Azure.Mcp.Server/build.yml @@ -12,6 +12,9 @@ parameters: - name: PackagePyPI type: boolean default: true +- name: PackageMCPB + type: boolean + default: true - name: IncludeNative type: boolean default: true @@ -32,5 +35,6 @@ extends: PackageDocker: ${{ parameters.PackageDocker }} PackageVSIX: ${{ parameters.PackageVSIX }} PackagePyPI: ${{ parameters.PackagePyPI }} + PackageMCPB: ${{ parameters.PackageMCPB }} IncludeNative: ${{ parameters.IncludeNative }} TestTimeoutInMinutes: ${{ parameters.TestTimeoutInMinutes }} diff --git a/servers/Azure.Mcp.Server/changelog-entries/1770669516879.yaml b/servers/Azure.Mcp.Server/changelog-entries/1770669516879.yaml new file mode 100644 index 000000000..15c4a1b35 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/1770669516879.yaml @@ -0,0 +1,4 @@ +pr: 1681 +changes: + - section: "Features Added" + description: "Added support for publishing server as an MCP Bundle (.mcpb)" \ No newline at end of file diff --git a/servers/Azure.Mcp.Server/mcpb/manifest.json b/servers/Azure.Mcp.Server/mcpb/manifest.json new file mode 100644 index 000000000..2480d38cb --- /dev/null +++ b/servers/Azure.Mcp.Server/mcpb/manifest.json @@ -0,0 +1,109 @@ +{ + "manifest_version": "0.3", + "name": "Azure.Mcp.Server", + "display_name": "Azure MCP Server", + "version": "2.0.0-beta.17", + "description": "Provides Model Context Protocol (MCP) integration and tooling for Microsoft Azure.", + "long_description": "All Azure MCP tools in a single server. The Azure MCP Server implements the [MCP specification](https://modelcontextprotocol.io) to create a seamless connection between AI agents and Azure services. Azure MCP Server can be used alone or with the [GitHub Copilot for Azure extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azure-github-copilot) in VS Code.", + "author": { + "name": "Microsoft", + "url": "https://www.microsoft.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/mcp.git" + }, + "homepage": "https://learn.microsoft.com/azure/developer/azure-mcp-server/get-started", + "documentation": "https://learn.microsoft.com/azure/developer/azure-mcp-server", + "support": "https://github.com/microsoft/mcp/issues", + "icon": "./servericon.png", + "server": { + "type": "binary", + "entry_point": "server/azmcp", + "mcp_config": { + "platform_overrides": { + "win32": { + "command": "${__dirname}\\server\\azmcp.exe", + "args": [] + } + }, + "command": "${__dirname}/server/azmcp", + "args": [ "server", "start" ], + "env": {} + } + }, + "tools": [], + "tools_generated": false, + "keywords": [ + "azure", + "mcp", + "model context protocol", + "server", + "microsoft", + "ai", + "artificial intelligence", + "acr", + "azure container registry", + "aks", + "kubernetes", + "app configuration", + "applens", + "application insights", + "app service", + "terraform", + "bicep", + "cloud", + "architecture", + "communication services", + "confidential ledger", + "cosmos", + "database", + "datadog", + "deploy", + "event grid", + "event hubs", + "foundry", + "function app", + "grafana", + "resource group", + "key vault", + "kusto", + "data explorer", + "load testing", + "managed lustre", + "marketplace", + "monitor", + "mysql", + "postgres", + "postgresql", + "quota", + "redis", + "resource health", + "role assignments", + "rbac", + "search", + "service bus", + "signalr", + "speech", + "sql", + "storage", + "subscription", + "virtual desktop", + "workbooks", + "azqr", + "quick reference", + "cli" + ], + "license": "MIT", + "privacy_policies": [ + "https://www.microsoft.com/privacy/privacystatement" + ], + "compatibility": { + "claude_desktop": ">=0.8.0", + "platforms": [ + "darwin", + "win32", + "linux" + ] + } +} \ No newline at end of file diff --git a/servers/Azure.Mcp.Server/src/Azure.Mcp.Server.csproj b/servers/Azure.Mcp.Server/src/Azure.Mcp.Server.csproj index 61fa7ca48..4c0070ac2 100644 --- a/servers/Azure.Mcp.Server/src/Azure.Mcp.Server.csproj +++ b/servers/Azure.Mcp.Server/src/Azure.Mcp.Server.csproj @@ -27,6 +27,9 @@ msmcp-azure azure,mcp,model-context-protocol,ai,llm + + + win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64 diff --git a/servers/Fabric.Mcp.Server/build.yml b/servers/Fabric.Mcp.Server/build.yml index 985679516..8cdea8c4c 100644 --- a/servers/Fabric.Mcp.Server/build.yml +++ b/servers/Fabric.Mcp.Server/build.yml @@ -26,8 +26,9 @@ extends: ServerName: Fabric.Mcp.Server PublishTarget: ${{ parameters.PublishTarget }} RunLiveTests: true - PackagePyPI: false PackageDocker: ${{ parameters.PackageDocker }} PackageVSIX: ${{ parameters.PackageVSIX }} + PackagePyPI: false + PackageMCPB: false IncludeNative: ${{ parameters.IncludeNative }} TestTimeoutInMinutes: ${{ parameters.TestTimeoutInMinutes }} diff --git a/servers/Fabric.Mcp.Server/mcpb/manifest.json b/servers/Fabric.Mcp.Server/mcpb/manifest.json new file mode 100644 index 000000000..af875b93f --- /dev/null +++ b/servers/Fabric.Mcp.Server/mcpb/manifest.json @@ -0,0 +1,60 @@ +{ + "manifest_version": "0.3", + "name": "Fabric.Mcp.Server", + "display_name": "Microsoft Fabric MCP Server", + "version": "0.0.0-beta.7", + "description": "Provides Model Context Protocol (MCP) integration and tooling for Microsoft Fabric.", + "long_description": "A local-first Model Context Protocol (MCP) server that provides AI agents with comprehensive access to Microsoft Fabric's public APIs, item definitions, and best practices. The Fabric MCP Server packages complete OpenAPI specifications into a single context layer for AI-assisted development—without connecting to live Fabric environments.", + "author": { + "name": "Microsoft", + "url": "https://www.microsoft.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/mcp.git" + }, + "homepage": "https://github.com/microsoft/mcp/tree/main/servers/Fabric.Mcp.Server", + "documentation": "https://github.com/microsoft/mcp/tree/main/servers/Fabric.Mcp.Server#readme", + "support": "https://github.com/microsoft/mcp/issues", + "icon": "./servericon.png", + "server": { + "type": "binary", + "entry_point": "server/fabmcp", + "mcp_config": { + "platform_overrides": { + "win32": { + "command": "${__dirname}\\server\\fabmcp.exe", + "args": [] + } + }, + "command": "${__dirname}/server/fabmcp", + "args": [ "server", "start" ], + "env": {} + } + }, + "tools": [], + "tools_generated": false, + "keywords": [ + "fabric", + "mcp", + "model context protocol", + "server", + "microsoft", + "ai", + "artificial intelligence", + "openapi", + "onelake" + ], + "license": "MIT", + "privacy_policies": [ + "https://www.microsoft.com/privacy/privacystatement" + ], + "compatibility": { + "claude_desktop": ">=0.8.0", + "platforms": [ + "darwin", + "win32", + "linux" + ] + } +} diff --git a/servers/Template.Mcp.Server/build.yml b/servers/Template.Mcp.Server/build.yml index 49157bfb9..521ba781f 100644 --- a/servers/Template.Mcp.Server/build.yml +++ b/servers/Template.Mcp.Server/build.yml @@ -12,6 +12,9 @@ parameters: - name: PackagePyPI type: boolean default: false +- name: PackageMCPB + type: boolean + default: false - name: IncludeNative type: boolean default: true @@ -32,6 +35,7 @@ extends: PackageDocker: ${{ parameters.PackageDocker }} PackageVSIX: ${{ parameters.PackageVSIX }} PackagePyPI: ${{ parameters.PackagePyPI }} + PackageMCPB: ${{ parameters.PackageMCPB }} IncludeNative: ${{ parameters.IncludeNative }} TestTimeoutInMinutes: ${{ parameters.TestTimeoutInMinutes }} IsTestPipeline: true diff --git a/servers/Template.Mcp.Server/mcpb/manifest.json b/servers/Template.Mcp.Server/mcpb/manifest.json new file mode 100644 index 000000000..10a41a970 --- /dev/null +++ b/servers/Template.Mcp.Server/mcpb/manifest.json @@ -0,0 +1,50 @@ +{ + "manifest_version": "0.3", + "name": "Template.Mcp.Server", + "display_name": "Template MCP Server", + "version": "0.0.12", + "description": "Basic Model Context Protocol implementation for Microsoft servers.", + "long_description": "A template Model Context Protocol (MCP) server that provides a starting point for building new servers. This server implements the [MCP specification](https://modelcontextprotocol.io) and can be used as a reference implementation for creating custom MCP servers.", + "author": { + "name": "Microsoft", + "url": "https://www.microsoft.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/mcp.git" + }, + "homepage": "https://github.com/microsoft/mcp/tree/main/servers/Template.Mcp.Server", + "documentation": "https://github.com/microsoft/mcp/tree/main/servers/Template.Mcp.Server#readme", + "support": "https://github.com/microsoft/mcp/issues", + "icon": "./servericon.png", + "server": { + "type": "binary", + "entry_point": "server/mcptmp", + "mcp_config": { + "platform_overrides": { + "win32": { + "command": "${__dirname}\\server\\mcptmp.exe", + "args": [] + } + }, + "command": "${__dirname}/server/mcptmp", + "args": [ "server", "start" ], + "env": {} + } + }, + "tools": [], + "tools_generated": false, + "keywords": [], + "license": "MIT", + "privacy_policies": [ + "https://www.microsoft.com/privacy/privacystatement" + ], + "compatibility": { + "claude_desktop": ">=0.8.0", + "platforms": [ + "darwin", + "win32", + "linux" + ] + } +} \ No newline at end of file diff --git a/servers/Template.Mcp.Server/src/Template.Mcp.Server.csproj b/servers/Template.Mcp.Server/src/Template.Mcp.Server.csproj index 04c17c30e..6b990da28 100644 --- a/servers/Template.Mcp.Server/src/Template.Mcp.Server.csproj +++ b/servers/Template.Mcp.Server/src/Template.Mcp.Server.csproj @@ -27,6 +27,9 @@ msmcp-template template,mcp,model-context-protocol,ai,llm + + + win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64