From 212bccde4300b76ea248882a1829403f5a9530d5 Mon Sep 17 00:00:00 2001 From: Joan Xie Date: Thu, 21 Aug 2025 11:47:51 -0700 Subject: [PATCH 1/3] feat: Add support for MCP Bundles (MCPB) in registry - Add MCPB as a new registry_name option - Add file_hashes field to Package model for integrity verification - Implement URL validation restricting MCPB packages to GitHub/GitLab hosts - Require SHA-256 hash for all MCPB packages - Add example MCPB package in documentation - Update schemas and OpenAPI specification Implements Option 1 from issue #260 - MCPB as an additional package type pointing to hosted .mcpb files with cryptographic verification. --- docs/server-json/examples.md | 32 +++++++++++++++ docs/server-json/registry-schema.json | 3 +- docs/server-json/schema.json | 10 +++++ docs/server-registry-api/openapi.yaml | 7 ++++ internal/model/model.go | 15 +++---- internal/service/registry_service.go | 57 +++++++++++++++++++++++++++ tools/validate-examples/main.go | 2 +- 7 files changed, 117 insertions(+), 9 deletions(-) diff --git a/docs/server-json/examples.md b/docs/server-json/examples.md index fe44ed33..244dbf86 100644 --- a/docs/server-json/examples.md +++ b/docs/server-json/examples.md @@ -389,6 +389,38 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6. } ``` +## MCP Bundle (MCPB) Package Example + +```json +{ + "name": "io.modelcontextprotocol/text-editor", + "description": "MCP Bundle server for advanced text editing capabilities", + "repository": { + "url": "https://github.com/modelcontextprotocol/text-editor-mcpb", + "source": "github", + "id": "mcpb-123ab-cdef4-56789-012ghi-jklmnopqrstu" + }, + "version_detail": { + "version": "1.0.2" + }, + "packages": [ + { + "registry_name": "mcpb", + "name": "https://github.com/modelcontextprotocol/text-editor-mcpb/releases/download/v1.0.2/text-editor.mcpb", + "version": "1.0.2", + "file_hashes": { + "sha-256": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce" + } + } + ] +} +``` + +This example shows an MCPB (MCP Bundle) package that: +- Is hosted on GitHub Releases (an allowlisted provider) +- Includes a SHA-256 hash for integrity verification +- Can be downloaded and executed directly by MCP clients that support MCPB + ## Deprecated Server Example ```json diff --git a/docs/server-json/registry-schema.json b/docs/server-json/registry-schema.json index 5b578984..3af1665e 100644 --- a/docs/server-json/registry-schema.json +++ b/docs/server-json/registry-schema.json @@ -22,7 +22,8 @@ "npm", "pypi", "docker", - "nuget" + "nuget", + "mcpb" ] }, "runtime_hint": { diff --git a/docs/server-json/schema.json b/docs/server-json/schema.json index 1d8e3cbb..c617be6c 100644 --- a/docs/server-json/schema.json +++ b/docs/server-json/schema.json @@ -97,6 +97,16 @@ "description": "Package version", "example": "1.0.2" }, + "file_hashes": { + "type": "object", + "description": "Cryptographic hashes of the package file for integrity verification. Keys are hash algorithm names (e.g., 'sha-256'), values are the hash strings.", + "additionalProperties": { + "type": "string" + }, + "example": { + "sha-256": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce" + } + }, "runtime_hint": { "type": "string", "description": "A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtime_arguments` are present.", diff --git a/docs/server-registry-api/openapi.yaml b/docs/server-registry-api/openapi.yaml index b4fcb243..c057dcdf 100644 --- a/docs/server-registry-api/openapi.yaml +++ b/docs/server-registry-api/openapi.yaml @@ -185,6 +185,13 @@ components: type: string description: Package version example: "1.0.2" + file_hashes: + type: object + description: Cryptographic hashes of the package file for integrity verification. Keys are hash algorithm names (e.g., 'sha-256'), values are the hash strings. + additionalProperties: + type: string + example: + sha-256: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce" runtime_hint: type: string description: A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtime_arguments` are present. diff --git a/internal/model/model.go b/internal/model/model.go index ad38bee8..fce45150 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -87,13 +87,14 @@ type Argument struct { } type Package struct { - RegistryName string `json:"registry_name" bson:"registry_name"` - Name string `json:"name" bson:"name"` - Version string `json:"version" bson:"version"` - RunTimeHint string `json:"runtime_hint,omitempty" bson:"runtime_hint,omitempty"` - RuntimeArguments []Argument `json:"runtime_arguments,omitempty" bson:"runtime_arguments,omitempty"` - PackageArguments []Argument `json:"package_arguments,omitempty" bson:"package_arguments,omitempty"` - EnvironmentVariables []KeyValueInput `json:"environment_variables,omitempty" bson:"environment_variables,omitempty"` + RegistryName string `json:"registry_name" bson:"registry_name"` + Name string `json:"name" bson:"name"` + Version string `json:"version" bson:"version"` + FileHashes map[string]string `json:"file_hashes,omitempty" bson:"file_hashes,omitempty"` + RunTimeHint string `json:"runtime_hint,omitempty" bson:"runtime_hint,omitempty"` + RuntimeArguments []Argument `json:"runtime_arguments,omitempty" bson:"runtime_arguments,omitempty"` + PackageArguments []Argument `json:"package_arguments,omitempty" bson:"package_arguments,omitempty"` + EnvironmentVariables []KeyValueInput `json:"environment_variables,omitempty" bson:"environment_variables,omitempty"` } // Remote represents a remote connection endpoint diff --git a/internal/service/registry_service.go b/internal/service/registry_service.go index d9798be3..97dec392 100644 --- a/internal/service/registry_service.go +++ b/internal/service/registry_service.go @@ -2,6 +2,9 @@ package service import ( "context" + "fmt" + "net/url" + "strings" "time" "github.com/modelcontextprotocol/registry/internal/database" @@ -84,6 +87,51 @@ func (s *registryServiceImpl) GetByID(id string) (*model.ServerDetail, error) { return serverDetail, nil } +// validateMCPBPackage validates MCPB packages to ensure they meet requirements +func validateMCPBPackage(pkg *model.Package) error { + // Validate that the URL is from an allowlisted host + parsedURL, err := url.Parse(pkg.Name) + if err != nil { + return fmt.Errorf("invalid MCPB package URL: %w", err) + } + + // Allowlist of trusted hosts for MCPB packages + allowedHosts := []string{ + "github.com", + "www.github.com", + "raw.githubusercontent.com", + "gitlab.com", + "www.gitlab.com", + } + + host := strings.ToLower(parsedURL.Host) + isAllowed := false + for _, allowed := range allowedHosts { + if host == allowed { + isAllowed = true + break + } + } + + if !isAllowed { + return fmt.Errorf("MCPB packages must be hosted on allowlisted providers (GitHub or GitLab). Host '%s' is not allowed", host) + } + + // Validate that file_hashes is provided for MCPB packages + if len(pkg.FileHashes) == 0 { + return fmt.Errorf("MCPB packages must include file_hashes for integrity verification") + } + + // Validate that at least SHA-256 is provided + if _, hasSHA256 := pkg.FileHashes["sha-256"]; !hasSHA256 { + if _, hasSHA256Alt := pkg.FileHashes["sha256"]; !hasSHA256Alt { + return fmt.Errorf("MCPB packages must include a SHA-256 hash") + } + } + + return nil +} + // Publish adds a new server detail to the registry func (s *registryServiceImpl) Publish(serverDetail *model.ServerDetail) error { // Create a timeout context for the database operation @@ -94,6 +142,15 @@ func (s *registryServiceImpl) Publish(serverDetail *model.ServerDetail) error { return database.ErrInvalidInput } + // Validate MCPB packages + for _, pkg := range serverDetail.Packages { + if strings.ToLower(pkg.RegistryName) == "mcpb" { + if err := validateMCPBPackage(&pkg); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + } + } + err := s.db.Publish(ctx, serverDetail) if err != nil { return err diff --git a/tools/validate-examples/main.go b/tools/validate-examples/main.go index e555279f..9ce83200 100644 --- a/tools/validate-examples/main.go +++ b/tools/validate-examples/main.go @@ -22,7 +22,7 @@ const ( // IMPORTANT: Only change this count if you have intentionally added or removed examples // from the examples.md file. This check prevents accidental formatting changes from // causing examples to be skipped during validation. - expectedExampleCount = 9 + expectedExampleCount = 10 ) func main() { From 2ee6e00481bac9420f2c7f85b60af4295bf3f871 Mon Sep 17 00:00:00 2001 From: Joan Xie Date: Fri, 22 Aug 2025 12:41:24 -0700 Subject: [PATCH 2/3] feat: add PackageLocation --- docs/server-json/examples.md | 30 ++++++++++----- docs/server-json/schema.json | 53 +++++++++++++++++++++------ docs/server-registry-api/openapi.yaml | 45 +++++++++++++++++------ internal/model/model.go | 16 ++++++-- internal/service/registry_service.go | 4 +- tools/publisher/main.go | 21 ++++++++--- tools/publisher/server.json | 12 ++++-- 7 files changed, 134 insertions(+), 47 deletions(-) diff --git a/docs/server-json/examples.md b/docs/server-json/examples.md index 244dbf86..afb0ba29 100644 --- a/docs/server-json/examples.md +++ b/docs/server-json/examples.md @@ -228,8 +228,10 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6. }, "packages": [ { - "registry_name": "nuget", - "name": "Knapcode.SampleMcpServer", + "location": { + "registry_name": "nuget", + "name": "Knapcode.SampleMcpServer" + }, "version": "0.5.0", "runtime_hint": "dnx", "environment_variables": [ @@ -261,8 +263,10 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6. }, "packages": [ { - "registry_name": "docker", - "name": "mcp/database-manager", + "location": { + "registry_name": "docker", + "name": "mcp/database-manager" + }, "version": "3.1.0", "runtime_arguments": [ { @@ -347,8 +351,10 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6. }, "packages": [ { - "registry_name": "npm", - "name": "@example/hybrid-mcp-server", + "location": { + "registry_name": "npm", + "name": "@example/hybrid-mcp-server" + }, "version": "1.5.0", "runtime_hint": "npx", "package_arguments": [ @@ -405,8 +411,10 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6. }, "packages": [ { - "registry_name": "mcpb", - "name": "https://github.com/modelcontextprotocol/text-editor-mcpb/releases/download/v1.0.2/text-editor.mcpb", + "location": { + "type": "mcpb", + "url": "https://github.com/modelcontextprotocol/text-editor-mcpb/releases/download/v1.0.2/text-editor.mcpb" + }, "version": "1.0.2", "file_hashes": { "sha-256": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce" @@ -438,8 +446,10 @@ This example shows an MCPB (MCP Bundle) package that: }, "packages": [ { - "registry_name": "npm", - "name": "@legacy/old-weather-server", + "location": { + "registry_name": "npm", + "name": "@legacy/old-weather-server" + }, "version": "0.9.5", "environment_variables": [ { diff --git a/docs/server-json/schema.json b/docs/server-json/schema.json index c617be6c..db5ae176 100644 --- a/docs/server-json/schema.json +++ b/docs/server-json/schema.json @@ -74,23 +74,52 @@ } } }, + "PackageLocation": { + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["registry_name", "name"], + "properties": { + "registry_name": { + "type": "string", + "description": "Package registry type", + "example": "npm" + }, + "name": { + "type": "string", + "description": "Package name in the registry", + "example": "@modelcontextprotocol/server-filesystem" + } + } + }, + { + "type": "object", + "required": ["type", "url"], + "properties": { + "type": { + "type": "string", + "enum": ["mcpb"], + "description": "Package location type for direct URL references" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Direct URL to the package file", + "example": "https://github.com/modelcontextprotocol/text-editor-mcpb/releases/download/v1.0.2/text-editor.mcpb" + } + } + } + ] + }, "Package": { "type": "object", "required": [ - "registry_name", - "name", - "version" + "location" ], "properties": { - "registry_name": { - "type": "string", - "description": "Package registry type", - "example": "npm" - }, - "name": { - "type": "string", - "description": "Package name in the registry", - "example": "io.modelcontextprotocol/filesystem" + "location": { + "$ref": "#/$defs/PackageLocation" }, "version": { "type": "string", diff --git a/docs/server-registry-api/openapi.yaml b/docs/server-registry-api/openapi.yaml index c057dcdf..ed73aa0a 100644 --- a/docs/server-registry-api/openapi.yaml +++ b/docs/server-registry-api/openapi.yaml @@ -166,21 +166,44 @@ components: description: Number of items in current page example: 30 + PackageLocation: + type: object + oneOf: + - type: object + required: + - registry_name + - name + properties: + registry_name: + type: string + description: Package registry type + example: "npm" + name: + type: string + description: Package name in the registry + example: "@modelcontextprotocol/server-filesystem" + - type: object + required: + - type + - url + properties: + type: + type: string + enum: ["mcpb"] + description: Package location type for direct URL references + url: + type: string + format: uri + description: Direct URL to the package file + example: "https://github.com/modelcontextprotocol/text-editor-mcpb/releases/download/v1.0.2/text-editor.mcpb" + Package: type: object required: - - registry_name - - name - - version + - location properties: - registry_name: - type: string - description: Package registry type - example: "npm" - name: - type: string - description: Package name in the registry - example: "io.modelcontextprotocol/filesystem" + location: + $ref: '#/components/schemas/PackageLocation' version: type: string description: Package version diff --git a/internal/model/model.go b/internal/model/model.go index fce45150..5413d4b9 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -86,10 +86,20 @@ type Argument struct { ValueHint string `json:"value_hint,omitempty" bson:"value_hint,omitempty"` } +// PackageLocation represents the location of a package +type PackageLocation struct { + // For registry-based packages + RegistryName string `json:"registry_name,omitempty" bson:"registry_name,omitempty"` + Name string `json:"name,omitempty" bson:"name,omitempty"` + + // For direct URL packages (e.g., MCPB) + Type string `json:"type,omitempty" bson:"type,omitempty"` + URL string `json:"url,omitempty" bson:"url,omitempty"` +} + type Package struct { - RegistryName string `json:"registry_name" bson:"registry_name"` - Name string `json:"name" bson:"name"` - Version string `json:"version" bson:"version"` + Location PackageLocation `json:"location" bson:"location"` + Version string `json:"version,omitempty" bson:"version,omitempty"` FileHashes map[string]string `json:"file_hashes,omitempty" bson:"file_hashes,omitempty"` RunTimeHint string `json:"runtime_hint,omitempty" bson:"runtime_hint,omitempty"` RuntimeArguments []Argument `json:"runtime_arguments,omitempty" bson:"runtime_arguments,omitempty"` diff --git a/internal/service/registry_service.go b/internal/service/registry_service.go index 97dec392..e2bce268 100644 --- a/internal/service/registry_service.go +++ b/internal/service/registry_service.go @@ -90,7 +90,7 @@ func (s *registryServiceImpl) GetByID(id string) (*model.ServerDetail, error) { // validateMCPBPackage validates MCPB packages to ensure they meet requirements func validateMCPBPackage(pkg *model.Package) error { // Validate that the URL is from an allowlisted host - parsedURL, err := url.Parse(pkg.Name) + parsedURL, err := url.Parse(pkg.Location.URL) if err != nil { return fmt.Errorf("invalid MCPB package URL: %w", err) } @@ -144,7 +144,7 @@ func (s *registryServiceImpl) Publish(serverDetail *model.ServerDetail) error { // Validate MCPB packages for _, pkg := range serverDetail.Packages { - if strings.ToLower(pkg.RegistryName) == "mcpb" { + if strings.ToLower(pkg.Location.Type) == "mcpb" { if err := validateMCPBPackage(&pkg); err != nil { return fmt.Errorf("validation failed: %w", err) } diff --git a/tools/publisher/main.go b/tools/publisher/main.go index d7813d7c..5841a4c3 100644 --- a/tools/publisher/main.go +++ b/tools/publisher/main.go @@ -41,10 +41,19 @@ type RuntimeArgument struct { ValueHint string `json:"value_hint"` } +type PackageLocation struct { + // For registry-based packages + RegistryName string `json:"registry_name,omitempty"` + Name string `json:"name,omitempty"` + + // For direct URL packages (e.g., MCPB) + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` +} + type Package struct { - RegistryName string `json:"registry_name"` - Name string `json:"name"` - Version string `json:"version"` + Location PackageLocation `json:"location"` + Version string `json:"version,omitempty"` RuntimeHint string `json:"runtime_hint,omitempty"` RuntimeArguments []RuntimeArgument `json:"runtime_arguments,omitempty"` PackageArguments []RuntimeArgument `json:"package_arguments,omitempty"` @@ -451,8 +460,10 @@ func createServerStructure( // Create package pkg := Package{ - RegistryName: registryName, - Name: packageName, + Location: PackageLocation{ + RegistryName: registryName, + Name: packageName, + }, Version: packageVersion, RuntimeHint: runtimeHint, RuntimeArguments: runtimeArguments, diff --git a/tools/publisher/server.json b/tools/publisher/server.json index b4952010..f65382b2 100644 --- a/tools/publisher/server.json +++ b/tools/publisher/server.json @@ -4,8 +4,10 @@ "status": "active", "packages": [ { - "registry_name": "npm", - "name": "io.github./", + "location": { + "registry_name": "npm", + "name": "io.github./" + }, "version": "0.2.23", "package_arguments": [ { @@ -25,8 +27,10 @@ } ] },{ - "registry_name": "docker", - "name": "io.github./-cli", + "location": { + "registry_name": "docker", + "name": "io.github./-cli" + }, "version": "0.123.223", "runtime_hint": "docker", "runtime_arguments": [ From 84cdc81d7d189e181e5df5ef8d63c36875798e0c Mon Sep 17 00:00:00 2001 From: Joan Xie Date: Fri, 22 Aug 2025 13:00:21 -0700 Subject: [PATCH 3/3] feat: Add file hash generation support to publisher CLI - Add hash generation during publish (can be disabled with --no-hash) - Add verify command to check existing hashes - Add hash-gen command to generate/update hashes - Support NPM and direct URL (MCPB) packages - Document implementation in docs/file-hashes.md The CLI tool now generates SHA-256 hashes at publish time. Registry validates these hashes to ensure package integrity. --- docs/file-hashes.md | 303 +++++++++++++++++++++++++++++++ tools/publisher/main.go | 390 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 687 insertions(+), 6 deletions(-) create mode 100644 docs/file-hashes.md diff --git a/docs/file-hashes.md b/docs/file-hashes.md new file mode 100644 index 00000000..ce83f48c --- /dev/null +++ b/docs/file-hashes.md @@ -0,0 +1,303 @@ +# File Hashes Implementation Guide + +## Overview + +File hashes provide integrity verification for MCP server packages. The CLI tool generates SHA-256 hashes at publish time, and the registry validates these hashes to ensure package integrity. + +## Implementation Strategy: CLI-Generated Hashes + +### Flow + +``` +1. Developer runs: mcp-publisher publish +2. CLI tool fetches package files from URLs +3. CLI tool computes SHA-256 hashes +4. CLI tool includes hashes in publish request +5. Registry validates hashes match the files +6. Registry stores server.json with validated hashes +``` + +### Responsibilities + +#### CLI Tool (Publisher) +- **Generates** hashes for package files +- **Includes** hashes in publish payload +- **Provides** option to skip hash generation (--no-hash flag) + +#### Registry +- **Validates** provided hashes against actual files +- **Stores** validated hashes in server.json +- **Accepts** submissions without hashes (optional field) +- **Rejects** submissions with invalid hashes + +#### Consumers +- **Verify** downloaded files match provided hashes (optional) +- **Decide** trust policy when hashes are absent + +## Hash Format + +### Structure +```json +{ + "file_hashes": { + "": "sha256:" + } +} +``` + +### Identifiers by Package Type + +#### NPM Packages +```json +{ + "file_hashes": { + "npm:@modelcontextprotocol/server-postgres@0.6.2": "sha256:abc123..." + } +} +``` + +#### Python Packages +```json +{ + "file_hashes": { + "pypi:mcp-server-postgres==0.6.2": "sha256:def456..." + } +} +``` + +#### GitHub Releases +```json +{ + "file_hashes": { + "github:owner/repo/v1.0.0/server.tar.gz": "sha256:789xyz..." + } +} +``` + +#### Direct URLs +```json +{ + "file_hashes": { + "https://example.com/packages/server-v1.0.0.tar.gz": "sha256:abc123..." + } +} +``` + +## CLI Tool Implementation + +### Hash Generation Process + +1. **Identify Package Files** + - Parse package_location from server.json + - Determine download URLs based on package type + - Handle multiple files if needed (e.g., wheels for different platforms) + +2. **Download Files** + - Use temporary directory for downloads + - Stream large files to avoid memory issues + - Implement retry logic for network failures + +3. **Compute Hashes** + - Use SHA-256 algorithm + - Process files in chunks for memory efficiency + - Generate consistent identifiers + +4. **Include in Publish** + - Add file_hashes to server.json before submission + - Validate JSON structure + +### CLI Commands + +```bash +# Standard publish with hash generation +mcp-publisher publish server.json + +# Skip hash generation (for testing or special cases) +mcp-publisher publish server.json --no-hash + +# Verify existing hashes without publishing +mcp-publisher verify server.json + +# Generate hashes and output to stdout (dry run) +mcp-publisher hash-gen server.json +``` + +## Registry Validation + +### Validation Process + +```python +def validate_file_hashes(server_json): + # Skip if no hashes provided (optional field) + if 'file_hashes' not in server_json: + return True + + for identifier, expected_hash in server_json['file_hashes'].items(): + # Download file from identifier + file_content = download_file(identifier) + + # Compute actual hash + actual_hash = compute_sha256(file_content) + + # Compare hashes + if f"sha256:{actual_hash}" != expected_hash: + raise ValidationError(f"Hash mismatch for {identifier}") + + return True +``` + +### Error Responses + +```json +{ + "error": "Hash validation failed", + "details": { + "npm:@example/server@1.0.0": { + "expected": "sha256:abc123...", + "actual": "sha256:def456...", + "status": "mismatch" + } + } +} +``` + +## Migration Path + +### Phase 1: Deploy Optional Field (Week 1) +- Update registry schema to include optional file_hashes +- Deploy registry without validation +- Document field for early adopters + +### Phase 2: Enable Validation (Week 2) +- Activate hash validation in registry +- Continue accepting entries without hashes +- Monitor validation failures + +### Phase 3: CLI Tool Support (Week 3-4) +- Release publisher tool with hash generation +- Documentation and examples +- Community feedback incorporation + +### Phase 4: Adoption Push (Month 2+) +- Encourage hash inclusion +- Consider making required for verified badges +- Never make fully mandatory (backward compatibility) + +## Security Considerations + +1. **Algorithm Choice** + - SHA-256 is current standard + - Design allows future algorithm updates + - Include algorithm in hash string (sha256:...) + +2. **Network Security** + - Always download over HTTPS + - Validate SSL certificates + - Implement download size limits + +3. **Trust Boundaries** + - Hashes verify integrity, not authenticity + - Registry validation prevents tampered submissions + - Consumers should verify independently + +## Example Implementation + +### Publisher Tool (Go) + +```go +func generateFileHashes(serverJSON *ServerJSON) (map[string]string, error) { + hashes := make(map[string]string) + + switch serverJSON.PackageLocation.Type { + case "npm": + url := getNPMPackageURL(serverJSON.PackageLocation.PackageName) + identifier := fmt.Sprintf("npm:%s", serverJSON.PackageLocation.PackageName) + hash, err := downloadAndHash(url) + if err != nil { + return nil, err + } + hashes[identifier] = fmt.Sprintf("sha256:%s", hash) + + case "pypi": + // Similar for Python packages + + case "github": + // Similar for GitHub releases + } + + return hashes, nil +} + +func downloadAndHash(url string) (string, error) { + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + hasher := sha256.New() + _, err = io.Copy(hasher, resp.Body) + if err != nil { + return "", err + } + + return hex.EncodeToString(hasher.Sum(nil)), nil +} +``` + +### Registry Validation (Go) + +```go +func (s *RegistryService) validateFileHashes(entry *RegistryEntry) error { + if entry.FileHashes == nil { + return nil // Optional field + } + + for identifier, expectedHash := range entry.FileHashes { + actualHash, err := s.computeHashForIdentifier(identifier) + if err != nil { + return fmt.Errorf("failed to validate %s: %w", identifier, err) + } + + if actualHash != expectedHash { + return fmt.Errorf("hash mismatch for %s", identifier) + } + } + + return nil +} +``` + +## Testing Strategy + +1. **Unit Tests** + - Hash computation correctness + - Identifier generation + - Error handling + +2. **Integration Tests** + - End-to-end publish with hashes + - Validation failure scenarios + - Network failure handling + +3. **Manual Testing** + - Various package types + - Large files + - Concurrent validations + +## FAQ + +**Q: What if package files are updated after publishing?** +A: The hash represents the file at publish time. Updates require new version publication. + +**Q: Can I update just the hashes?** +A: No, hashes are part of the version. New hashes require new version. + +**Q: What about private packages?** +A: The registry must be able to access files for validation. Private packages need accessible URLs during validation. + +**Q: Are hashes required?** +A: No, file_hashes is optional to maintain backward compatibility. + +**Q: What about multiple files per package?** +A: Each file gets its own hash entry in the file_hashes object. \ No newline at end of file diff --git a/tools/publisher/main.go b/tools/publisher/main.go index 5841a4c3..e1162a9f 100644 --- a/tools/publisher/main.go +++ b/tools/publisher/main.go @@ -3,6 +3,8 @@ package main import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "flag" @@ -61,12 +63,13 @@ type Package struct { } type ServerJSON struct { - Name string `json:"name"` - Description string `json:"description"` - Status string `json:"status,omitempty"` - Repository Repository `json:"repository"` - VersionDetail VersionDetail `json:"version_detail"` - Packages []Package `json:"packages"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status,omitempty"` + Repository Repository `json:"repository"` + VersionDetail VersionDetail `json:"version_detail"` + Packages []Package `json:"packages"` + FileHashes map[string]string `json:"file_hashes,omitempty"` } func main() { @@ -81,6 +84,10 @@ func main() { err = publishCommand() case "create": err = createCommand() + case "verify": + err = verifyCommand() + case "hash-gen": + err = hashGenCommand() default: printUsage() } @@ -95,6 +102,8 @@ func printUsage() { fmt.Fprint(os.Stdout, "Usage:\n") fmt.Fprint(os.Stdout, " mcp-publisher publish [flags] Publish a server.json file to the registry\n") fmt.Fprint(os.Stdout, " mcp-publisher create [flags] Create a new server.json file\n") + fmt.Fprint(os.Stdout, " mcp-publisher verify [flags] Verify file hashes in a server.json file\n") + fmt.Fprint(os.Stdout, " mcp-publisher hash-gen [flags] Generate file hashes for a server.json file\n") fmt.Fprint(os.Stdout, "\n") fmt.Fprint(os.Stdout, "Use 'mcp-publisher --help' for more information about a command.\n") } @@ -106,12 +115,14 @@ func publishCommand() error { var mcpFilePath string var forceLogin bool var authMethod string + var noHash bool // Command-line flags for configuration publishFlags.StringVar(®istryURL, "registry-url", "", "URL of the registry (required)") publishFlags.StringVar(&mcpFilePath, "mcp-file", "", "path to the MCP file (required)") publishFlags.BoolVar(&forceLogin, "login", false, "force a new login even if a token exists") publishFlags.StringVar(&authMethod, "auth-method", "github-at", "authentication method (default: github-at)") + publishFlags.BoolVar(&noHash, "no-hash", false, "skip file hash generation") // Set custom usage function publishFlags.Usage = func() { @@ -124,6 +135,7 @@ func publishCommand() error { fmt.Fprint(os.Stdout, " --mcp-file string path to the MCP file (required)\n") fmt.Fprint(os.Stdout, " --login force a new login even if a token exists\n") fmt.Fprint(os.Stdout, " --auth-method string authentication method (default: github-at)\n") + fmt.Fprint(os.Stdout, " --no-hash skip file hash generation\n") } if err := publishFlags.Parse(os.Args[2:]); err != nil { @@ -141,6 +153,28 @@ func publishCommand() error { return fmt.Errorf("error reading MCP file: %w", err) } + // Generate file hashes if not disabled + if !noHash { + log.Println("Generating file hashes...") + var serverJSON ServerJSON + if err := json.Unmarshal(mcpData, &serverJSON); err != nil { + return fmt.Errorf("error parsing server.json: %w", err) + } + + hashes, err := generateFileHashes(&serverJSON) + if err != nil { + log.Printf("Warning: Could not generate file hashes: %v", err) + log.Println("Continuing without hashes...") + } else if len(hashes) > 0 { + serverJSON.FileHashes = hashes + mcpData, err = json.MarshalIndent(serverJSON, "", " ") + if err != nil { + return fmt.Errorf("error marshaling server.json with hashes: %w", err) + } + log.Printf("Generated %d file hash(es)", len(hashes)) + } + } + var authProvider auth.Provider // Determine the authentication method switch authMethod { case "github-at": @@ -523,3 +557,347 @@ func smartSplit(command string) []string { return parts } + +// generateFileHashes generates SHA-256 hashes for package files +func generateFileHashes(serverJSON *ServerJSON) (map[string]string, error) { + hashes := make(map[string]string) + + for _, pkg := range serverJSON.Packages { + // Handle different package location types + switch { + case pkg.Location.Type == "mcpb": + // Direct URL package (MCPB) + if pkg.Location.URL != "" { + identifier := pkg.Location.URL + hash, err := downloadAndHash(pkg.Location.URL) + if err != nil { + return nil, fmt.Errorf("failed to hash %s: %w", identifier, err) + } + hashes[identifier] = fmt.Sprintf("sha256:%s", hash) + } + + case pkg.Location.RegistryName == "npm" && pkg.Location.Name != "": + // NPM package + packageFullName := pkg.Location.Name + if pkg.Version != "" { + packageFullName = fmt.Sprintf("%s@%s", pkg.Location.Name, pkg.Version) + } + + // For NPM packages, we need to fetch the package metadata to get the tarball URL + tarballURL, err := getNPMTarballURL(pkg.Location.Name, pkg.Version) + if err != nil { + log.Printf("Warning: Could not get NPM tarball URL for %s: %v", packageFullName, err) + continue + } + + hash, err := downloadAndHash(tarballURL) + if err != nil { + return nil, fmt.Errorf("failed to hash NPM package %s: %w", packageFullName, err) + } + + identifier := fmt.Sprintf("npm:%s", packageFullName) + hashes[identifier] = fmt.Sprintf("sha256:%s", hash) + + case pkg.Location.RegistryName == "pypi" && pkg.Location.Name != "": + // Python package + packageFullName := pkg.Location.Name + if pkg.Version != "" { + packageFullName = fmt.Sprintf("%s==%s", pkg.Location.Name, pkg.Version) + } + + // For PyPI packages, we would need to fetch from PyPI API + // This is a simplified version - real implementation would need PyPI API calls + log.Printf("Warning: PyPI hash generation not yet implemented for %s", packageFullName) + + case pkg.Location.RegistryName == "docker" && pkg.Location.Name != "": + // Docker images - skip hash generation as they have their own digest system + log.Printf("Info: Skipping hash generation for Docker image %s (uses digests)", pkg.Location.Name) + + default: + log.Printf("Warning: Unsupported package type for hash generation: %+v", pkg.Location) + } + } + + return hashes, nil +} + +// downloadAndHash downloads a file and computes its SHA-256 hash +func downloadAndHash(url string) (string, error) { + // Create temporary file for download + tmpFile, err := os.CreateTemp("", "mcp-hash-*") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + // Download the file + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("failed to download from %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + // Stream to temp file and compute hash simultaneously + hasher := sha256.New() + writer := io.MultiWriter(tmpFile, hasher) + + _, err = io.Copy(writer, resp.Body) + if err != nil { + return "", fmt.Errorf("failed to download/hash file: %w", err) + } + + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +// getNPMTarballURL fetches the tarball URL for an NPM package +func getNPMTarballURL(packageName, version string) (string, error) { + // Construct NPM registry API URL + registryURL := fmt.Sprintf("https://registry.npmjs.org/%s", packageName) + if version != "" { + registryURL = fmt.Sprintf("%s/%s", registryURL, version) + } + + resp, err := http.Get(registryURL) + if err != nil { + return "", fmt.Errorf("failed to fetch NPM metadata: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("NPM registry returned status %d", resp.StatusCode) + } + + var metadata map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { + return "", fmt.Errorf("failed to parse NPM metadata: %w", err) + } + + // Extract tarball URL from metadata + // The structure depends on whether we fetched a specific version or the latest + if dist, ok := metadata["dist"].(map[string]interface{}); ok { + if tarball, ok := dist["tarball"].(string); ok { + return tarball, nil + } + } + + // If no specific version, try to get the latest version's tarball + if versions, ok := metadata["versions"].(map[string]interface{}); ok { + // Get the latest version + var latestVersion string + if distTags, ok := metadata["dist-tags"].(map[string]interface{}); ok { + if latest, ok := distTags["latest"].(string); ok { + latestVersion = latest + } + } + + if latestVersion != "" { + if versionData, ok := versions[latestVersion].(map[string]interface{}); ok { + if dist, ok := versionData["dist"].(map[string]interface{}); ok { + if tarball, ok := dist["tarball"].(string); ok { + return tarball, nil + } + } + } + } + } + + return "", fmt.Errorf("could not find tarball URL in NPM metadata") +} + +// verifyCommand verifies file hashes in a server.json file +func verifyCommand() error { + verifyFlags := flag.NewFlagSet("verify", flag.ExitOnError) + + var mcpFilePath string + verifyFlags.StringVar(&mcpFilePath, "mcp-file", "server.json", "path to the MCP file") + + verifyFlags.Usage = func() { + fmt.Fprint(os.Stdout, "Usage: mcp-publisher verify [flags]\n") + fmt.Fprint(os.Stdout, "\n") + fmt.Fprint(os.Stdout, "Verify file hashes in a server.json file\n") + fmt.Fprint(os.Stdout, "\n") + fmt.Fprint(os.Stdout, "Flags:\n") + fmt.Fprint(os.Stdout, " --mcp-file string path to the MCP file (default: server.json)\n") + } + + if err := verifyFlags.Parse(os.Args[2:]); err != nil { + return err + } + + // Read the server.json file + data, err := os.ReadFile(mcpFilePath) + if err != nil { + return fmt.Errorf("error reading file: %w", err) + } + + var serverJSON ServerJSON + if err := json.Unmarshal(data, &serverJSON); err != nil { + return fmt.Errorf("error parsing JSON: %w", err) + } + + if len(serverJSON.FileHashes) == 0 { + log.Println("No file hashes found in server.json") + return nil + } + + log.Printf("Verifying %d file hash(es)...\n", len(serverJSON.FileHashes)) + + allValid := true + for identifier, expectedHash := range serverJSON.FileHashes { + // Extract the hash algorithm and value + parts := strings.SplitN(expectedHash, ":", 2) + if len(parts) != 2 || parts[0] != "sha256" { + log.Printf("❌ %s: Invalid hash format\n", identifier) + allValid = false + continue + } + + // Determine the URL to download based on identifier + var downloadURL string + if strings.HasPrefix(identifier, "http://") || strings.HasPrefix(identifier, "https://") { + downloadURL = identifier + } else if strings.HasPrefix(identifier, "npm:") { + packageName := strings.TrimPrefix(identifier, "npm:") + // Parse package name and version + atIndex := strings.LastIndex(packageName, "@") + var name, version string + if atIndex > 0 { + name = packageName[:atIndex] + version = packageName[atIndex+1:] + } else { + name = packageName + } + + url, err := getNPMTarballURL(name, version) + if err != nil { + log.Printf("❌ %s: Failed to get download URL: %v\n", identifier, err) + allValid = false + continue + } + downloadURL = url + } else { + log.Printf("⚠️ %s: Unsupported identifier type, skipping\n", identifier) + continue + } + + // Download and compute hash + actualHash, err := downloadAndHash(downloadURL) + if err != nil { + log.Printf("❌ %s: Failed to download/hash: %v\n", identifier, err) + allValid = false + continue + } + + if parts[1] == actualHash { + log.Printf("✅ %s: Valid\n", identifier) + } else { + log.Printf("❌ %s: Hash mismatch\n", identifier) + log.Printf(" Expected: %s\n", parts[1]) + log.Printf(" Actual: %s\n", actualHash) + allValid = false + } + } + + if allValid { + log.Println("\n✅ All hashes verified successfully!") + return nil + } else { + return fmt.Errorf("\n❌ Hash verification failed") + } +} + +// hashGenCommand generates file hashes for a server.json file +func hashGenCommand() error { + hashGenFlags := flag.NewFlagSet("hash-gen", flag.ExitOnError) + + var mcpFilePath string + var outputPath string + var dryRun bool + + hashGenFlags.StringVar(&mcpFilePath, "mcp-file", "server.json", "path to the MCP file") + hashGenFlags.StringVar(&outputPath, "output", "", "output file path (default: update input file)") + hashGenFlags.BoolVar(&dryRun, "dry-run", false, "print hashes to stdout without modifying files") + + hashGenFlags.Usage = func() { + fmt.Fprint(os.Stdout, "Usage: mcp-publisher hash-gen [flags]\n") + fmt.Fprint(os.Stdout, "\n") + fmt.Fprint(os.Stdout, "Generate file hashes for a server.json file\n") + fmt.Fprint(os.Stdout, "\n") + fmt.Fprint(os.Stdout, "Flags:\n") + fmt.Fprint(os.Stdout, " --mcp-file string path to the MCP file (default: server.json)\n") + fmt.Fprint(os.Stdout, " --output string output file path (default: update input file)\n") + fmt.Fprint(os.Stdout, " --dry-run print hashes to stdout without modifying files\n") + } + + if err := hashGenFlags.Parse(os.Args[2:]); err != nil { + return err + } + + // Read the server.json file + data, err := os.ReadFile(mcpFilePath) + if err != nil { + return fmt.Errorf("error reading file: %w", err) + } + + var serverJSON ServerJSON + if err := json.Unmarshal(data, &serverJSON); err != nil { + return fmt.Errorf("error parsing JSON: %w", err) + } + + // Generate hashes + log.Println("Generating file hashes...") + hashes, err := generateFileHashes(&serverJSON) + if err != nil { + return fmt.Errorf("failed to generate hashes: %w", err) + } + + if len(hashes) == 0 { + log.Println("No hashes generated (no supported package types found)") + return nil + } + + log.Printf("Generated %d file hash(es)\n", len(hashes)) + + // Update the server JSON with new hashes + serverJSON.FileHashes = hashes + + // Marshal to JSON + output, err := json.MarshalIndent(serverJSON, "", " ") + if err != nil { + return fmt.Errorf("error marshaling JSON: %w", err) + } + + if dryRun { + // Print hashes to stdout + fmt.Println("\nGenerated hashes:") + for id, hash := range hashes { + fmt.Printf(" %s: %s\n", id, hash) + } + fmt.Println("\nFull server.json with hashes:") + fmt.Println(string(output)) + } else { + // Determine output path + if outputPath == "" { + outputPath = mcpFilePath + } + + // Write to file + if err := os.WriteFile(outputPath, output, 0644); err != nil { + return fmt.Errorf("error writing file: %w", err) + } + + log.Printf("✅ Updated %s with file hashes\n", outputPath) + + // Display the hashes + for id, hash := range hashes { + log.Printf(" %s: %s\n", id, hash) + } + } + + return nil +}