From 632cbce815b8a22db91cc4a23430e917dcbd4f07 Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov Date: Sat, 11 Oct 2025 10:53:45 +0300 Subject: [PATCH] Refactor package types to use dedicated schema types per package type (NPM, PyPI, NuGet, OCI, MCPB) (#634) --- cmd/publisher/commands/init.go | 75 ++++-- data/seed.json | 12 +- docs/reference/api/openapi.yaml | 1 - .../server-json/generic-server-json.md | 10 +- docs/reference/server-json/server.schema.json | 1 - .../v0/publish_registry_validation_test.go | 3 - .../009_migrate_canonical_package_refs.sql | 200 +++++++++++++++ internal/validators/registries/mcpb.go | 39 +-- internal/validators/registries/mcpb_test.go | 66 +++++ internal/validators/registries/npm.go | 5 + internal/validators/registries/nuget.go | 5 + internal/validators/registries/oci.go | 72 +++--- .../validators/registries/oci_ref_parser.go | 161 ++++++++++++ .../registries/oci_ref_parser_test.go | 229 ++++++++++++++++++ internal/validators/registries/oci_test.go | 99 ++++++-- internal/validators/registries/pypi.go | 5 + internal/validators/validators_test.go | 10 +- pkg/model/types.go | 38 ++- 18 files changed, 884 insertions(+), 147 deletions(-) create mode 100644 internal/database/migrations/009_migrate_canonical_package_refs.sql create mode 100644 internal/validators/registries/oci_ref_parser.go create mode 100644 internal/validators/registries/oci_ref_parser_test.go diff --git a/cmd/publisher/commands/init.go b/cmd/publisher/commands/init.go index 6b2ac27..a7d5ea8 100644 --- a/cmd/publisher/commands/init.go +++ b/cmd/publisher/commands/init.go @@ -267,36 +267,63 @@ func createServerJSON( packageType, packageIdentifier, packageVersion string, envVars []model.KeyValueInput, ) apiv0.ServerJSON { - // Determine registry type and base URL - var registryType, registryBaseURL string + // Create package based on type + var pkg model.Package + switch packageType { case model.RegistryTypeNPM: - registryType = model.RegistryTypeNPM - registryBaseURL = model.RegistryURLNPM + pkg = model.Package{ + RegistryType: model.RegistryTypeNPM, + Identifier: packageIdentifier, + Version: packageVersion, + EnvironmentVariables: envVars, + Transport: model.Transport{ + Type: model.TransportTypeStdio, + }, + } case model.RegistryTypePyPI: - registryType = model.RegistryTypePyPI - registryBaseURL = model.RegistryURLPyPI + pkg = model.Package{ + RegistryType: model.RegistryTypePyPI, + Identifier: packageIdentifier, + Version: packageVersion, + EnvironmentVariables: envVars, + Transport: model.Transport{ + Type: model.TransportTypeStdio, + }, + } case model.RegistryTypeOCI: - registryType = model.RegistryTypeOCI - registryBaseURL = model.RegistryURLDocker + // OCI packages use canonical references: registry/namespace/image:tag + // Format: docker.io/username/image:version + canonicalRef := fmt.Sprintf("docker.io/%s:%s", packageIdentifier, packageVersion) + pkg = model.Package{ + RegistryType: model.RegistryTypeOCI, + Identifier: canonicalRef, + // No Version field for OCI - it's embedded in the canonical reference + EnvironmentVariables: envVars, + Transport: model.Transport{ + Type: model.TransportTypeStdio, + }, + } case "url": - registryType = "url" - registryBaseURL = "" + pkg = model.Package{ + RegistryType: "url", + Identifier: packageIdentifier, + Version: packageVersion, + EnvironmentVariables: envVars, + Transport: model.Transport{ + Type: model.TransportTypeStdio, + }, + } default: - registryType = packageType - registryBaseURL = "" - } - - // Create package - pkg := model.Package{ - RegistryType: registryType, - RegistryBaseURL: registryBaseURL, - Identifier: packageIdentifier, - Version: packageVersion, - EnvironmentVariables: envVars, - Transport: model.Transport{ - Type: model.TransportTypeStdio, - }, + pkg = model.Package{ + RegistryType: packageType, + Identifier: packageIdentifier, + Version: packageVersion, + EnvironmentVariables: envVars, + Transport: model.Transport{ + Type: model.TransportTypeStdio, + }, + } } // Create server structure diff --git a/data/seed.json b/data/seed.json index f731345..6ffb9c5 100644 --- a/data/seed.json +++ b/data/seed.json @@ -12,7 +12,9 @@ { "src": "https://airtable.com/images/favicon/favicon-32x32.png", "mimeType": "image/png", - "sizes": ["32x32"] + "sizes": [ + "32x32" + ] } ], "packages": [ @@ -35,8 +37,7 @@ }, { "registryType": "oci", - "identifier": "domdomegg/airtable-mcp-server", - "version": "1.7.2", + "identifier": "docker.io/domdomegg/airtable-mcp-server:1.7.2", "runtimeHint": "docker", "transport": { "type": "stdio" @@ -53,7 +54,6 @@ { "registryType": "mcpb", "identifier": "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb", - "version": "1.7.2", "fileSha256": "8220de07a08ebe908f04da139ea03dbfe29758141347e945da60535fb7bcca20", "transport": { "type": "stdio" @@ -90,8 +90,7 @@ }, { "registryType": "oci", - "identifier": "domdomegg/airtable-mcp-server", - "version": "1.7.3", + "identifier": "docker.io/domdomegg/airtable-mcp-server:1.7.3", "runtimeHint": "docker", "transport": { "type": "stdio" @@ -108,7 +107,6 @@ { "registryType": "mcpb", "identifier": "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.3/airtable-mcp-server.mcpb", - "version": "1.7.3", "fileSha256": "0f28a9129cfebd262dfb77854c872355d21401bb3e056575b3027081f5d570ca", "transport": { "type": "stdio" diff --git a/docs/reference/api/openapi.yaml b/docs/reference/api/openapi.yaml index 95c4242..50b46c6 100644 --- a/docs/reference/api/openapi.yaml +++ b/docs/reference/api/openapi.yaml @@ -270,7 +270,6 @@ components: required: - registryType - identifier - - version - transport properties: registryType: diff --git a/docs/reference/server-json/generic-server-json.md b/docs/reference/server-json/generic-server-json.md index c6a0b4b..b59929f 100644 --- a/docs/reference/server-json/generic-server-json.md +++ b/docs/reference/server-json/generic-server-json.md @@ -191,9 +191,7 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp }, { "registryType": "oci", - "registryBaseUrl": "https://docker.io", - "identifier": "mcp/filesystem", - "version": "1.0.2", + "identifier": "docker.io/mcp/filesystem:1.0.2", "transport": { "type": "stdio" }, @@ -405,9 +403,7 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6. "packages": [ { "registryType": "oci", - "registryBaseUrl": "https://docker.io", - "identifier": "example/database-manager-mcp", - "version": "3.1.0", + "identifier": "docker.io/example/database-manager-mcp:3.1.0", "transport": { "type": "stdio" }, @@ -602,9 +598,7 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6. "packages": [ { "registryType": "mcpb", - "registryBaseUrl": "https://github.com", "identifier": "https://github.com/modelcontextprotocol/text-editor-mcpb/releases/download/v1.0.2/text-editor.mcpb", - "version": "1.0.2", "fileSha256": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", "transport": { "type": "stdio" diff --git a/docs/reference/server-json/server.schema.json b/docs/reference/server-json/server.schema.json index a033f2f..bcd7643 100644 --- a/docs/reference/server-json/server.schema.json +++ b/docs/reference/server-json/server.schema.json @@ -288,7 +288,6 @@ "required": [ "registryType", "identifier", - "version", "transport" ], "type": "object" diff --git a/internal/api/handlers/v0/publish_registry_validation_test.go b/internal/api/handlers/v0/publish_registry_validation_test.go index 3529630..01a61dd 100644 --- a/internal/api/handlers/v0/publish_registry_validation_test.go +++ b/internal/api/handlers/v0/publish_registry_validation_test.go @@ -95,7 +95,6 @@ func TestPublishRegistryValidation(t *testing.T) { { RegistryType: model.RegistryTypeMCPB, Identifier: "https://github.com/microsoft/playwright-mcp/releases/download/v0.0.36/playwright-mcp-extension-v0.0.36.zip", - Version: "0.0.36", FileSHA256: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", Transport: model.Transport{ Type: model.TransportTypeStdio, @@ -146,7 +145,6 @@ func TestPublishRegistryValidation(t *testing.T) { { RegistryType: model.RegistryTypeMCPB, Identifier: "https://github.com/microsoft/playwright-mcp/releases/download/v0.0.36/playwright-mcp-extension-v0.0.36.zip", - Version: "1.0.0", FileSHA256: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", Transport: model.Transport{ Type: model.TransportTypeStdio, @@ -206,7 +204,6 @@ func TestPublishRegistryValidation(t *testing.T) { { RegistryType: model.RegistryTypeMCPB, Identifier: "https://github.com/microsoft/playwright-mcp/releases/download/v0.0.36/playwright-mcp-extension-v0.0.36.zip", - Version: "1.0.0", FileSHA256: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", Transport: model.Transport{ Type: model.TransportTypeStdio, diff --git a/internal/database/migrations/009_migrate_canonical_package_refs.sql b/internal/database/migrations/009_migrate_canonical_package_refs.sql new file mode 100644 index 0000000..db7fd91 --- /dev/null +++ b/internal/database/migrations/009_migrate_canonical_package_refs.sql @@ -0,0 +1,200 @@ +-- Migrate package references to canonical format +-- This migration: +-- - Converts OCI packages to use canonical single-line references (registry/namespace/image:tag) +-- - Removes redundant version and registryBaseUrl fields from MCPB packages +-- - Removes package-type-specific forbidden fields (e.g., fileSha256 from non-MCPB packages) +-- - Ensures all packages have required transport field + +-- Helper function to convert OCI package to canonical reference format +CREATE OR REPLACE FUNCTION convert_oci_package_to_canonical(pkg jsonb) +RETURNS jsonb +LANGUAGE plpgsql +AS $$ +DECLARE + result jsonb; + registry_url text; + registry_host text; + identifier text; + version_str text; + canonical_ref text; +BEGIN + -- Start with the original package + result := pkg; + + -- Only process OCI packages + IF pkg->>'registryType' != 'oci' THEN + RETURN result; + END IF; + + -- Get current values + registry_url := pkg->>'registryBaseUrl'; + identifier := pkg->>'identifier'; + version_str := pkg->>'version'; + + -- Skip if already in canonical format (no registryBaseUrl and no separate version) + -- Canonical format has registry in identifier like "ghcr.io/owner/repo:tag" + IF registry_url IS NULL AND (identifier LIKE '%:%' OR identifier LIKE '%@sha256:%') THEN + RETURN result; + END IF; + + -- Extract registry host from registryBaseUrl + IF registry_url IS NOT NULL THEN + registry_host := regexp_replace(registry_url, '^https?://', ''); + ELSE + -- Default to docker.io if no registry specified + registry_host := 'docker.io'; + END IF; + + -- Build canonical reference + -- Format: registry/namespace/image:tag + IF version_str IS NOT NULL AND version_str != '' THEN + canonical_ref := registry_host || '/' || identifier || ':' || version_str; + ELSE + -- Default to 'latest' tag if no version specified + canonical_ref := registry_host || '/' || identifier || ':latest'; + END IF; + + -- Update identifier to canonical reference + result := jsonb_set(result, '{identifier}', to_jsonb(canonical_ref)); + + -- Remove registryBaseUrl field (no longer needed for OCI) + result := result - 'registryBaseUrl'; + + -- Remove version field (now part of identifier for OCI) + result := result - 'version'; + + RETURN result; +END; +$$; + +-- Helper function to convert MCPB package to canonical reference format +CREATE OR REPLACE FUNCTION convert_mcpb_package_to_canonical(pkg jsonb) +RETURNS jsonb +LANGUAGE plpgsql +AS $$ +DECLARE + result jsonb; +BEGIN + -- Start with the original package + result := pkg; + + -- Only process MCPB packages + IF pkg->>'registryType' != 'mcpb' THEN + RETURN result; + END IF; + + -- Remove version field if it exists (version is embedded in the download URL) + result := result - 'version'; + + -- Remove registryBaseUrl field if it exists (not needed for MCPB) + result := result - 'registryBaseUrl'; + + RETURN result; +END; +$$; + +-- Helper function to remove package-type-specific forbidden fields +CREATE OR REPLACE FUNCTION remove_forbidden_fields(pkg jsonb) +RETURNS jsonb +LANGUAGE plpgsql +AS $$ +DECLARE + result jsonb; + registry_type text; +BEGIN + result := pkg; + registry_type := pkg->>'registryType'; + + -- Remove fileSha256 from non-MCPB packages (it's MCPB-only) + IF registry_type != 'mcpb' AND result ? 'fileSha256' THEN + result := result - 'fileSha256'; + END IF; + + RETURN result; +END; +$$; + +-- Helper function to ensure transport field exists +CREATE OR REPLACE FUNCTION ensure_transport_field(pkg jsonb) +RETURNS jsonb +LANGUAGE plpgsql +AS $$ +DECLARE + result jsonb; + registry_type text; + runtime_hint text; +BEGIN + result := pkg; + + -- If transport already exists, return as-is + IF result ? 'transport' THEN + RETURN result; + END IF; + + -- Get registry type and runtime hint + registry_type := pkg->>'registryType'; + runtime_hint := pkg->>'runtimeHint'; + + -- Add default transport based on package type + -- Most packages use stdio transport + result := jsonb_set(result, '{transport}', '{"type": "stdio"}'::jsonb); + + RETURN result; +END; +$$; + +-- Helper function to convert all packages in a packages array +CREATE OR REPLACE FUNCTION convert_packages_array(packages jsonb) +RETURNS jsonb +LANGUAGE plpgsql +AS $$ +DECLARE + result jsonb := '[]'::jsonb; + pkg jsonb; +BEGIN + -- Handle null or empty arrays + IF packages IS NULL OR jsonb_array_length(packages) = 0 THEN + RETURN packages; + END IF; + + -- Process each package + FOR pkg IN SELECT * FROM jsonb_array_elements(packages) + LOOP + -- First convert OCI packages to canonical format + pkg := convert_oci_package_to_canonical(pkg); + + -- Then convert MCPB packages to canonical format + pkg := convert_mcpb_package_to_canonical(pkg); + + -- Remove package-type-specific forbidden fields + pkg := remove_forbidden_fields(pkg); + + -- Finally ensure transport field exists + pkg := ensure_transport_field(pkg); + + -- Add to result array + result := result || jsonb_build_array(pkg); + END LOOP; + + RETURN result; +END; +$$; + +-- Migrate all server packages to canonical format +-- After migration 003, all data is stored in the 'value' JSONB column +UPDATE servers +SET value = jsonb_set( + value, + '{packages}', + convert_packages_array(value->'packages') +) +WHERE value ? 'packages' + AND value->'packages' IS NOT NULL + AND jsonb_array_length(value->'packages') > 0; + +-- Clean up helper functions +DROP FUNCTION IF EXISTS convert_oci_package_to_canonical(jsonb); +DROP FUNCTION IF EXISTS convert_mcpb_package_to_canonical(jsonb); +DROP FUNCTION IF EXISTS remove_forbidden_fields(jsonb); +DROP FUNCTION IF EXISTS ensure_transport_field(jsonb); +DROP FUNCTION IF EXISTS convert_packages_array(jsonb); \ No newline at end of file diff --git a/internal/validators/registries/mcpb.go b/internal/validators/registries/mcpb.go index 7847a6b..72293e6 100644 --- a/internal/validators/registries/mcpb.go +++ b/internal/validators/registries/mcpb.go @@ -27,23 +27,20 @@ func ValidateMCPB(ctx context.Context, pkg model.Package, _ string) error { return ErrMissingIdentifierForMCPB } + // Validate that old format fields are not present + // MCPB packages use URL in identifier, version is embedded in the URL + if pkg.Version != "" { + return fmt.Errorf("MCPB packages must not have 'version' field - version should be embedded in the download URL") + } + if pkg.RegistryBaseURL != "" { + return fmt.Errorf("MCPB packages must not have 'registryBaseUrl' field - use the full download URL in 'identifier' instead") + } + err := validateMCPBUrl(pkg.Identifier) if err != nil { return err } - inferredBaseURL, err := inferMCPBRegistryBaseURL(pkg.Identifier) - if err != nil { - return err - } - - if pkg.RegistryBaseURL == "" { - pkg.RegistryBaseURL = inferredBaseURL - } else if pkg.RegistryBaseURL != inferredBaseURL { - return fmt.Errorf("MCPB package '%s' has inconsistent registry base URL: %s (expected: %s)", - pkg.Identifier, pkg.RegistryBaseURL, inferredBaseURL) - } - // Parse the URL to validate format url, err := url.Parse(pkg.Identifier) if err != nil { @@ -161,21 +158,3 @@ func isValidGitLabReleaseURL(path string) bool { return false } - -// inferMCPBRegistryBaseURL infers the registry base URL from an MCPB identifier -func inferMCPBRegistryBaseURL(identifier string) (string, error) { - parsedURL, err := url.Parse(identifier) - if err != nil { - return "", err - } - - host := strings.ToLower(parsedURL.Host) - switch host { - case "github.com", "www.github.com": - return model.RegistryURLGitHub, nil - case "gitlab.com", "www.gitlab.com": - return model.RegistryURLGitLab, nil - default: - return "", fmt.Errorf("invalid host for MCPB package: %s, expected github or gitlab", host) - } -} diff --git a/internal/validators/registries/mcpb_test.go b/internal/validators/registries/mcpb_test.go index 9db6de0..b3a0c33 100644 --- a/internal/validators/registries/mcpb_test.go +++ b/internal/validators/registries/mcpb_test.go @@ -119,3 +119,69 @@ func TestValidateMCPB(t *testing.T) { }) } } + +func TestValidateMCPB_RejectsExtraFields(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + pkg model.Package + errorMessage string + }{ + { + name: "MCPB package with version field should be rejected", + pkg: model.Package{ + RegistryType: model.RegistryTypeMCPB, + Identifier: "https://github.com/example/repo/releases/download/v1.0.0/server.mcpb", + Version: "1.0.0", + FileSHA256: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", + }, + errorMessage: "MCPB packages must not have 'version' field", + }, + { + name: "MCPB package with registryBaseUrl should be rejected", + pkg: model.Package{ + RegistryType: model.RegistryTypeMCPB, + Identifier: "https://github.com/example/repo/releases/download/v1.0.0/server.mcpb", + RegistryBaseURL: "https://github.com", + FileSHA256: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", + }, + errorMessage: "MCPB packages must not have 'registryBaseUrl' field", + }, + { + name: "MCPB package with both extra fields should fail on version first", + pkg: model.Package{ + RegistryType: model.RegistryTypeMCPB, + Identifier: "https://github.com/example/repo/releases/download/v1.0.0/server.mcpb", + Version: "1.0.0", + RegistryBaseURL: "https://github.com", + FileSHA256: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", + }, + errorMessage: "MCPB packages must not have 'version' field", + }, + { + name: "MCPB package with canonical format should pass extra field validation", + pkg: model.Package{ + RegistryType: model.RegistryTypeMCPB, + Identifier: "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb", + FileSHA256: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", + }, + errorMessage: "", // Should pass extra field check (will succeed as this is a real package) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := registries.ValidateMCPB(ctx, tt.pkg, "com.example/test") + + if tt.errorMessage != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMessage) + } else if err != nil { + // Should not fail with extra field error (may pass completely for real package) + assert.NotContains(t, err.Error(), "must not have 'version'") + assert.NotContains(t, err.Error(), "must not have 'registryBaseUrl'") + } + }) + } +} diff --git a/internal/validators/registries/npm.go b/internal/validators/registries/npm.go index 4a1449c..458040c 100644 --- a/internal/validators/registries/npm.go +++ b/internal/validators/registries/npm.go @@ -41,6 +41,11 @@ func ValidateNPM(ctx context.Context, pkg model.Package, serverName string) erro return ErrMissingVersionForNPM } + // Validate that MCPB-specific fields are not present + if pkg.FileSHA256 != "" { + return fmt.Errorf("NPM packages must not have 'fileSha256' field - this is only for MCPB packages") + } + // Validate that the registry base URL matches NPM exactly if pkg.RegistryBaseURL != model.RegistryURLNPM { return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s", diff --git a/internal/validators/registries/nuget.go b/internal/validators/registries/nuget.go index 01a2d47..9e2f836 100644 --- a/internal/validators/registries/nuget.go +++ b/internal/validators/registries/nuget.go @@ -28,6 +28,11 @@ func ValidateNuGet(ctx context.Context, pkg model.Package, serverName string) er return ErrMissingIdentifierForNuget } + // Validate that MCPB-specific fields are not present + if pkg.FileSHA256 != "" { + return fmt.Errorf("NuGet packages must not have 'fileSha256' field - this is only for MCPB packages") + } + // Validate that the registry base URL matches NuGet exactly if pkg.RegistryBaseURL != model.RegistryURLNuGet { return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s", diff --git a/internal/validators/registries/oci.go b/internal/validators/registries/oci.go index ca96f32..fb8a92c 100644 --- a/internal/validators/registries/oci.go +++ b/internal/validators/registries/oci.go @@ -7,7 +7,6 @@ import ( "fmt" "log" "net/http" - "strings" "time" "github.com/modelcontextprotocol/registry/pkg/model" @@ -78,60 +77,73 @@ type OCIImageConfig struct { } `json:"config"` } -// ValidateOCI validates that an OCI image contains the correct MCP server name annotation +// ValidateOCI validates that an OCI image contains the correct MCP server name annotation. +// Supports canonical OCI references including: +// - registry/namespace/image:tag +// - registry/namespace/image@sha256:digest +// - registry/namespace/image:tag@sha256:digest +// - namespace/image:tag (defaults to docker.io) func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) error { - // Set default registry base URL if empty - if pkg.RegistryBaseURL == "" { - pkg.RegistryBaseURL = model.RegistryURLDocker - } - if pkg.Identifier == "" { return ErrMissingIdentifierForOCI } - // we need version (tag) to look up the image manifest - if pkg.Version == "" { - return ErrMissingVersionForOCI + // Validate that old format fields are not present + if pkg.RegistryBaseURL != "" { + return fmt.Errorf("OCI packages must not have 'registryBaseUrl' field - use canonical reference in 'identifier' instead (e.g., 'docker.io/owner/image:1.0.0')") + } + if pkg.Version != "" { + return fmt.Errorf("OCI packages must not have 'version' field - include version in 'identifier' instead (e.g., 'docker.io/owner/image:1.0.0')") + } + if pkg.FileSHA256 != "" { + return fmt.Errorf("OCI packages must not have 'fileSha256' field - this is only for MCPB packages") } - // Validate that the registry base URL is supported - if err := validateRegistryURL(pkg.RegistryBaseURL); err != nil { + // Parse the canonical OCI reference from the identifier + ociRef, err := ParseOCIReference(pkg.Identifier) + if err != nil { + return fmt.Errorf("invalid OCI reference: %w", err) + } + + // Validate that the registry is supported + registryBaseURL := ociRef.GetRegistryBaseURL() + if err := validateRegistryURL(registryBaseURL); err != nil { return err } client := &http.Client{Timeout: 10 * time.Second} - // Parse image reference (namespace/repo or repo) - namespace, repo, err := parseImageReference(pkg.Identifier) - if err != nil { - return fmt.Errorf("invalid OCI image reference: %w", err) + // Get registry configuration + registryConfig := getRegistryConfig(registryBaseURL, ociRef.Namespace, ociRef.Image) + if registryConfig == nil { + return fmt.Errorf("unsupported registry: %s", registryBaseURL) } - // Get registry configuration - registryConfig := getRegistryConfig(pkg.RegistryBaseURL, namespace, repo) - if registryConfig == nil { - return fmt.Errorf("unsupported registry: %s", pkg.RegistryBaseURL) + // Determine what to use for manifest lookup: digest if available (most secure), otherwise tag + manifestRef := ociRef.Tag + if ociRef.Digest != "" { + manifestRef = ociRef.Digest } // Get the image manifest - manifest, err := fetchImageManifest(ctx, client, registryConfig, namespace, repo, pkg.Version) + manifest, err := fetchImageManifest(ctx, client, registryConfig, ociRef.Namespace, ociRef.Image, manifestRef) if err != nil { // Handle rate limiting explicitly - skip validation if errors.Is(err, ErrRateLimited) { - log.Printf("Skipping OCI validation for %s/%s:%s due to rate limiting", namespace, repo, pkg.Version) + log.Printf("Skipping OCI validation for %s due to rate limiting", ociRef.String()) return nil } return err } // Get config digest from manifest - configDigest, err := getConfigDigestFromManifest(ctx, client, registryConfig, namespace, repo, manifest) + configDigest, err := getConfigDigestFromManifest(ctx, client, registryConfig, ociRef.Namespace, ociRef.Image, manifest) if err != nil { return err } // Validate server name annotation - return validateServerNameAnnotation(ctx, client, registryConfig, namespace, repo, pkg.Version, configDigest, serverName) + return validateServerNameAnnotation(ctx, client, registryConfig, ociRef.Namespace, ociRef.Image, ociRef.Tag, configDigest, serverName) } // validateRegistryURL validates that the registry base URL is supported @@ -229,18 +241,6 @@ func validateServerNameAnnotation(ctx context.Context, client *http.Client, regi return nil } -func parseImageReference(identifier string) (string, string, error) { - parts := strings.Split(identifier, "/") - switch len(parts) { - case 2: - return parts[0], parts[1], nil - case 1: - return "library", parts[0], nil - default: - return "", "", fmt.Errorf("invalid image reference: %s", identifier) - } -} - // getRegistryAuthToken retrieves an authentication token from a registry func getRegistryAuthToken(ctx context.Context, client *http.Client, config *RegistryConfig) (string, error) { if config.AuthURL == "" { diff --git a/internal/validators/registries/oci_ref_parser.go b/internal/validators/registries/oci_ref_parser.go new file mode 100644 index 0000000..c0e41d2 --- /dev/null +++ b/internal/validators/registries/oci_ref_parser.go @@ -0,0 +1,161 @@ +package registries + +import ( + "fmt" + "regexp" + "strings" +) + +const ( + // defaultOCIRegistry is the default registry when none is specified + defaultOCIRegistry = "docker.io" + // defaultOCINamespace is the default namespace for official images + defaultOCINamespace = "library" +) + +// OCIReference represents a parsed OCI image reference +type OCIReference struct { + Registry string // e.g., "ghcr.io", "docker.io" + Namespace string // e.g., "owner", "library" + Image string // e.g., "repo" + Tag string // e.g., "v1.0.0", "latest" (optional) + Digest string // e.g., "sha256:abc..." (optional) +} + +// ParseOCIReference parses a canonical OCI image reference. +// Supported formats: +// - registry/namespace/image:tag +// - registry/namespace/image@digest +// - registry/namespace/image:tag@digest +// - namespace/image:tag (defaults to docker.io) +// - image:tag (defaults to docker.io/library) +func ParseOCIReference(ref string) (*OCIReference, error) { + if ref == "" { + return nil, fmt.Errorf("OCI reference cannot be empty") + } + + result := &OCIReference{} + + // Split by @ to separate digest + var mainPart string + if idx := strings.Index(ref, "@"); idx >= 0 { + mainPart = ref[:idx] + result.Digest = ref[idx+1:] + + // Validate digest format + if !strings.HasPrefix(result.Digest, "sha256:") { + return nil, fmt.Errorf("invalid digest format: must start with 'sha256:'") + } + digestPattern := regexp.MustCompile(`^sha256:[a-fA-F0-9]{64}$`) + if !digestPattern.MatchString(result.Digest) { + return nil, fmt.Errorf("invalid digest format: must be sha256 followed by 64 hex characters") + } + } else { + mainPart = ref + } + + // Split by : to separate tag + var pathPart string + if idx := strings.LastIndex(mainPart, ":"); idx >= 0 { + // Check if this looks like a registry with port (e.g., localhost:5000) + // or a tag. If there's a / after the :, it's likely a port. + if idx > 0 && !strings.Contains(mainPart[idx:], "/") { + pathPart = mainPart[:idx] + result.Tag = mainPart[idx+1:] + } else { + pathPart = mainPart + } + } else { + pathPart = mainPart + } + + // Parse the path (registry/namespace/image or namespace/image or image) + parts := strings.Split(pathPart, "/") + + switch len(parts) { + case 1: + // Just image name: "postgres:16" -> docker.io/library/postgres:16 + result.Registry = defaultOCIRegistry + result.Namespace = defaultOCINamespace + result.Image = parts[0] + + case 2: + // namespace/image: "owner/repo:tag" -> docker.io/owner/repo:tag + // OR registry/image: "ghcr.io/image" -> ghcr.io/library/image + // Heuristic: if first part looks like a domain (contains . or :), treat as registry + if strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":") { + result.Registry = parts[0] + result.Namespace = defaultOCINamespace + result.Image = parts[1] + } else { + result.Registry = defaultOCIRegistry + result.Namespace = parts[0] + result.Image = parts[1] + } + + case 3: + // registry/namespace/image: "ghcr.io/owner/repo:tag" + result.Registry = parts[0] + result.Namespace = parts[1] + result.Image = parts[2] + + default: + // More than 3 parts could be multi-level namespace (e.g., ghcr.io/org/team/repo) + // Take first as registry, last as image, everything in between as namespace + if len(parts) > 3 { + result.Registry = parts[0] + result.Namespace = strings.Join(parts[1:len(parts)-1], "/") + result.Image = parts[len(parts)-1] + } else { + return nil, fmt.Errorf("invalid OCI reference format: %s", ref) + } + } + + // Validate we have either a tag or digest + if result.Tag == "" && result.Digest == "" { + return nil, fmt.Errorf("OCI reference must include either a tag or digest: %s", ref) + } + + // Default tag to "latest" if only digest is provided (for display purposes) + // Note: when pulling by digest, the tag is ignored by registries + if result.Tag == "" && result.Digest != "" { + result.Tag = "latest" + } + + return result, nil +} + +// String returns the canonical string representation of the OCI reference +func (r *OCIReference) String() string { + var sb strings.Builder + + sb.WriteString(r.Registry) + sb.WriteString("/") + sb.WriteString(r.Namespace) + sb.WriteString("/") + sb.WriteString(r.Image) + + if r.Tag != "" { + sb.WriteString(":") + sb.WriteString(r.Tag) + } + + if r.Digest != "" { + sb.WriteString("@") + sb.WriteString(r.Digest) + } + + return sb.String() +} + +// GetRegistryBaseURL returns the full registry URL (e.g., "https://docker.io" or "https://ghcr.io") +func (r *OCIReference) GetRegistryBaseURL() string { + switch r.Registry { + case "docker.io", "registry.docker.io", "index.docker.io": + return "https://docker.io" + case "ghcr.io": + return "https://ghcr.io" + default: + return "https://" + r.Registry + } +} diff --git a/internal/validators/registries/oci_ref_parser_test.go b/internal/validators/registries/oci_ref_parser_test.go new file mode 100644 index 0000000..71f6baf --- /dev/null +++ b/internal/validators/registries/oci_ref_parser_test.go @@ -0,0 +1,229 @@ +package registries_test + +import ( + "testing" + + "github.com/modelcontextprotocol/registry/internal/validators/registries" +) + +func TestParseOCIReference(t *testing.T) { + tests := []struct { + name string + input string + want *registries.OCIReference + wantError bool + }{ + { + name: "full reference with tag", + input: "ghcr.io/owner/repo:v1.0.0", + want: ®istries.OCIReference{ + Registry: "ghcr.io", + Namespace: "owner", + Image: "repo", + Tag: "v1.0.0", + Digest: "", + }, + }, + { + name: "full reference with digest only", + input: "ghcr.io/owner/repo@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + want: ®istries.OCIReference{ + Registry: "ghcr.io", + Namespace: "owner", + Image: "repo", + Tag: "latest", // Default when only digest provided + Digest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + }, + }, + { + name: "full reference with tag and digest", + input: "ghcr.io/owner/repo:v1.0.0@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + want: ®istries.OCIReference{ + Registry: "ghcr.io", + Namespace: "owner", + Image: "repo", + Tag: "v1.0.0", + Digest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + }, + }, + { + name: "docker.io short form", + input: "owner/repo:latest", + want: ®istries.OCIReference{ + Registry: "docker.io", + Namespace: "owner", + Image: "repo", + Tag: "latest", + Digest: "", + }, + }, + { + name: "docker.io library image", + input: "postgres:16", + want: ®istries.OCIReference{ + Registry: "docker.io", + Namespace: "library", + Image: "postgres", + Tag: "16", + Digest: "", + }, + }, + { + name: "docker.io library image with digest", + input: "postgres@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + want: ®istries.OCIReference{ + Registry: "docker.io", + Namespace: "library", + Image: "postgres", + Tag: "latest", + Digest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + }, + }, + { + name: "multi-level namespace", + input: "ghcr.io/org/team/repo:v2.0.0", + want: ®istries.OCIReference{ + Registry: "ghcr.io", + Namespace: "org/team", + Image: "repo", + Tag: "v2.0.0", + Digest: "", + }, + }, + { + name: "empty reference", + input: "", + wantError: true, + }, + { + name: "no tag or digest", + input: "ghcr.io/owner/repo", + wantError: true, + }, + { + name: "invalid digest format", + input: "ghcr.io/owner/repo@md5:abc123", + wantError: true, + }, + { + name: "invalid digest length", + input: "ghcr.io/owner/repo@sha256:abc123", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := registries.ParseOCIReference(tt.input) + + if tt.wantError { + if err == nil { + t.Errorf("registries.ParseOCIReference() expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("registries.ParseOCIReference() unexpected error: %v", err) + return + } + + if got.Registry != tt.want.Registry { + t.Errorf("Registry = %v, want %v", got.Registry, tt.want.Registry) + } + if got.Namespace != tt.want.Namespace { + t.Errorf("Namespace = %v, want %v", got.Namespace, tt.want.Namespace) + } + if got.Image != tt.want.Image { + t.Errorf("Image = %v, want %v", got.Image, tt.want.Image) + } + if got.Tag != tt.want.Tag { + t.Errorf("Tag = %v, want %v", got.Tag, tt.want.Tag) + } + if got.Digest != tt.want.Digest { + t.Errorf("Digest = %v, want %v", got.Digest, tt.want.Digest) + } + }) + } +} + +func TestOCIReference_String(t *testing.T) { + tests := []struct { + name string + ref *registries.OCIReference + want string + }{ + { + name: "full reference with tag", + ref: ®istries.OCIReference{ + Registry: "ghcr.io", + Namespace: "owner", + Image: "repo", + Tag: "v1.0.0", + }, + want: "ghcr.io/owner/repo:v1.0.0", + }, + { + name: "full reference with digest", + ref: ®istries.OCIReference{ + Registry: "ghcr.io", + Namespace: "owner", + Image: "repo", + Tag: "latest", + Digest: "sha256:abc123", + }, + want: "ghcr.io/owner/repo:latest@sha256:abc123", + }, + { + name: "docker.io library image", + ref: ®istries.OCIReference{ + Registry: "docker.io", + Namespace: "library", + Image: "postgres", + Tag: "16", + }, + want: "docker.io/library/postgres:16", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.ref.String(); got != tt.want { + t.Errorf("OCIReference.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestOCIReference_GetRegistryBaseURL(t *testing.T) { + tests := []struct { + name string + registry string + want string + }{ + { + name: "docker.io", + registry: "docker.io", + want: "https://docker.io", + }, + { + name: "ghcr.io", + registry: "ghcr.io", + want: "https://ghcr.io", + }, + { + name: "custom registry", + registry: "my-registry.com", + want: "https://my-registry.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ref := ®istries.OCIReference{Registry: tt.registry} + if got := ref.GetRegistryBaseURL(); got != tt.want { + t.Errorf("GetRegistryBaseURL() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/validators/registries/oci_test.go b/internal/validators/registries/oci_test.go index ea611ea..76be707 100644 --- a/internal/validators/registries/oci_test.go +++ b/internal/validators/registries/oci_test.go @@ -138,11 +138,10 @@ func TestValidateOCI_RealPackages(t *testing.T) { func TestValidateOCI_UnsupportedRegistry(t *testing.T) { ctx := context.Background() + // Test with unsupported registry in canonical reference format pkg := model.Package{ - RegistryType: model.RegistryTypeOCI, - RegistryBaseURL: "https://unsupported-registry.com", - Identifier: "test/image", - Version: "latest", + RegistryType: model.RegistryTypeOCI, + Identifier: "unsupported-registry.com/test/image:latest", } err := registries.ValidateOCI(ctx, pkg, "com.example/test") @@ -155,34 +154,32 @@ func TestValidateOCI_SupportedRegistries(t *testing.T) { ctx := context.Background() tests := []struct { - name string - registryURL string - expected bool + name string + identifier string + expected bool }{ { - name: "Docker Hub should be supported", - registryURL: model.RegistryURLDocker, - expected: true, + name: "Docker Hub should be supported", + identifier: "docker.io/test/image:latest", + expected: true, }, { - name: "GHCR should be supported", - registryURL: model.RegistryURLGHCR, - expected: true, + name: "GHCR should be supported", + identifier: "ghcr.io/test/image:latest", + expected: true, }, { - name: "Unsupported registry should fail", - registryURL: "https://quay.io", - expected: false, + name: "Unsupported registry should fail", + identifier: "quay.io/test/image:latest", + expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pkg := model.Package{ - RegistryType: model.RegistryTypeOCI, - RegistryBaseURL: tt.registryURL, - Identifier: "test/image", - Version: "latest", + RegistryType: model.RegistryTypeOCI, + Identifier: tt.identifier, } err := registries.ValidateOCI(ctx, pkg, "com.example/test") @@ -199,3 +196,65 @@ func TestValidateOCI_SupportedRegistries(t *testing.T) { }) } } + +func TestValidateOCI_RejectsOldFormat(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + pkg model.Package + errorMessage string + }{ + { + name: "OCI package with registryBaseUrl should be rejected", + pkg: model.Package{ + RegistryType: model.RegistryTypeOCI, + RegistryBaseURL: "https://docker.io", + Identifier: "docker.io/test/image:latest", + }, + errorMessage: "OCI packages must not have 'registryBaseUrl' field", + }, + { + name: "OCI package with version field should be rejected", + pkg: model.Package{ + RegistryType: model.RegistryTypeOCI, + Identifier: "docker.io/test/image:latest", + Version: "1.0.0", + }, + errorMessage: "OCI packages must not have 'version' field", + }, + { + name: "OCI package with both old format fields should fail on registryBaseUrl first", + pkg: model.Package{ + RegistryType: model.RegistryTypeOCI, + RegistryBaseURL: "https://docker.io", + Identifier: "test/image", + Version: "1.0.0", + }, + errorMessage: "OCI packages must not have 'registryBaseUrl' field", + }, + { + name: "OCI package with canonical format should pass old format validation", + pkg: model.Package{ + RegistryType: model.RegistryTypeOCI, + Identifier: "docker.io/test/image:latest", + }, + errorMessage: "", // Should pass old format check (will fail later due to image not existing) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := registries.ValidateOCI(ctx, tt.pkg, "com.example/test") + + if tt.errorMessage != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMessage) + } else if err != nil { + // Should not fail with old format error (may fail with other errors like image not found) + assert.NotContains(t, err.Error(), "must not have 'registryBaseUrl'") + assert.NotContains(t, err.Error(), "must not have 'version'") + } + }) + } +} diff --git a/internal/validators/registries/pypi.go b/internal/validators/registries/pypi.go index 7ae98d6..62ce22e 100644 --- a/internal/validators/registries/pypi.go +++ b/internal/validators/registries/pypi.go @@ -39,6 +39,11 @@ func ValidatePyPI(ctx context.Context, pkg model.Package, serverName string) err return ErrMissingVersionForPyPi } + // Validate that MCPB-specific fields are not present + if pkg.FileSHA256 != "" { + return fmt.Errorf("PyPI packages must not have 'fileSha256' field - this is only for MCPB packages") + } + // Validate that the registry base URL matches PyPI exactly if pkg.RegistryBaseURL != model.RegistryURLPyPI { return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s", diff --git a/internal/validators/validators_test.go b/internal/validators/validators_test.go index 6a8c6d4..3a1aa43 100644 --- a/internal/validators/validators_test.go +++ b/internal/validators/validators_test.go @@ -1616,16 +1616,14 @@ func TestValidate_RegistryTypesAndUrls(t *testing.T) { {"valid_npm", "io.github.domdomegg/airtable-mcp-server", model.RegistryTypeNPM, "", "airtable-mcp-server", "1.7.2", "", false}, {"valid_pypi", "io.github.domdomegg/time-mcp-pypi", model.RegistryTypePyPI, model.RegistryURLPyPI, "time-mcp-pypi", "1.0.1", "", false}, {"valid_pypi", "io.github.domdomegg/time-mcp-pypi", model.RegistryTypePyPI, "", "time-mcp-pypi", "1.0.1", "", false}, - {"valid_oci", "io.github.domdomegg/airtable-mcp-server", model.RegistryTypeOCI, model.RegistryURLDocker, "domdomegg/airtable-mcp-server", "1.7.2", "", false}, + {"valid_oci", "io.github.domdomegg/airtable-mcp-server", model.RegistryTypeOCI, "", "domdomegg/airtable-mcp-server:1.7.2", "", "", false}, {"valid_nuget", "io.github.domdomegg/time-mcp-server", model.RegistryTypeNuGet, model.RegistryURLNuGet, "TimeMcpServer", "1.0.2", "", false}, {"valid_nuget", "io.github.domdomegg/time-mcp-server", model.RegistryTypeNuGet, "", "TimeMcpServer", "1.0.2", "", false}, - {"valid_mcpb_github", "io.github.domdomegg/airtable-mcp-server", model.RegistryTypeMCPB, model.RegistryURLGitHub, "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb", "1.7.2", "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", false}, - {"valid_mcpb_github", "io.github.domdomegg/airtable-mcp-server", model.RegistryTypeMCPB, "", "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb", "1.7.2", "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", false}, - {"valid_mcpb_gitlab", "io.gitlab.fforster/gitlab-mcp", model.RegistryTypeMCPB, model.RegistryURLGitLab, "https://gitlab.com/fforster/gitlab-mcp/-/releases/v1.31.0/downloads/gitlab-mcp_1.31.0_Linux_x86_64.tar.gz", "1.31.0", "abc123ef4567890abcdef1234567890abcdef1234567890abcdef1234567890", false}, // this is not actually a valid mcpb, but it's the closest I can get for testing for now - {"valid_mcpb_gitlab", "io.gitlab.fforster/gitlab-mcp", model.RegistryTypeMCPB, "", "https://gitlab.com/fforster/gitlab-mcp/-/releases/v1.31.0/downloads/gitlab-mcp_1.31.0_Linux_x86_64.tar.gz", "1.31.0", "abc123ef4567890abcdef1234567890abcdef1234567890abcdef1234567890", false}, // this is not actually a valid mcpb, but it's the closest I can get for testing for now + {"valid_mcpb_github", "io.github.domdomegg/airtable-mcp-server", model.RegistryTypeMCPB, "", "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb", "", "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", false}, + {"valid_mcpb_gitlab", "io.gitlab.fforster/gitlab-mcp", model.RegistryTypeMCPB, "", "https://gitlab.com/fforster/gitlab-mcp/-/releases/v1.31.0/downloads/gitlab-mcp_1.31.0_Linux_x86_64.tar.gz", "", "abc123ef4567890abcdef1234567890abcdef1234567890abcdef1234567890", false}, // this is not actually a valid mcpb, but it's the closest I can get for testing for now // Test MCPB without file hash (should fail) - {"invalid_mcpb_no_hash", "io.github.domdomegg/airtable-mcp-server", model.RegistryTypeMCPB, model.RegistryURLGitHub, "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb", "1.7.2", "", true}, + {"invalid_mcpb_no_hash", "io.github.domdomegg/airtable-mcp-server", model.RegistryTypeMCPB, "", "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb", "", "", true}, // Invalid registry types (should fail) {"invalid_maven", "io.github.domdomegg/airtable-mcp-server", "maven", model.RegistryURLNPM, "airtable-mcp-server", "1.7.2", "", true}, diff --git a/pkg/model/types.go b/pkg/model/types.go index ef2dbbb..1f8eb88 100644 --- a/pkg/model/types.go +++ b/pkg/model/types.go @@ -16,20 +16,36 @@ type Transport struct { Headers []KeyValueInput `json:"headers,omitempty"` } -// Package represents a package configuration +// Package represents a package configuration. +// The RegistryType field determines which other fields are relevant: +// - NPM: RegistryType, Identifier (package name), Version, RegistryBaseURL (optional) +// - PyPI: RegistryType, Identifier (package name), Version, RegistryBaseURL (optional) +// - NuGet: RegistryType, Identifier (package ID), Version, RegistryBaseURL (optional) +// - OCI: RegistryType, Identifier (full image reference like "ghcr.io/owner/repo:tag") +// - MCPB: RegistryType, Identifier (download URL), FileSHA256 (required) type Package struct { - // RegistryType indicates how to download packages (e.g., "npm", "pypi", "oci", "mcpb") + // RegistryType indicates how to download packages (e.g., "npm", "pypi", "oci", "nuget", "mcpb") RegistryType string `json:"registryType" minLength:"1"` - // RegistryBaseURL is the base URL of the package registry + // RegistryBaseURL is the base URL of the package registry (used by npm, pypi, nuget; not used by oci, mcpb) RegistryBaseURL string `json:"registryBaseUrl,omitempty"` - // Identifier is the package identifier - either a package name (for registries) or URL (for direct downloads) - Identifier string `json:"identifier" minLength:"1"` - Version string `json:"version" minLength:"1"` - FileSHA256 string `json:"fileSha256,omitempty"` - RunTimeHint string `json:"runtimeHint,omitempty"` - Transport Transport `json:"transport,omitempty"` - RuntimeArguments []Argument `json:"runtimeArguments,omitempty"` - PackageArguments []Argument `json:"packageArguments,omitempty"` + // Identifier is the package identifier: + // - For NPM/PyPI/NuGet: package name or ID + // - For OCI: full image reference (e.g., "ghcr.io/owner/repo:v1.0.0") + // - For MCPB: direct download URL + Identifier string `json:"identifier" minLength:"1"` + // Version is the package version (used by npm, pypi, nuget; not used by oci, mcpb where version is in the identifier) + Version string `json:"version,omitempty" minLength:"1"` + // FileSHA256 is the SHA-256 hash for integrity verification (required for mcpb, optional for others) + FileSHA256 string `json:"fileSha256,omitempty"` + // RunTimeHint suggests the appropriate runtime for the package + RunTimeHint string `json:"runtimeHint,omitempty"` + // Transport is required and specifies the transport protocol configuration + Transport Transport `json:"transport"` + // RuntimeArguments are passed to the package's runtime command (e.g., docker, npx) + RuntimeArguments []Argument `json:"runtimeArguments,omitempty"` + // PackageArguments are passed to the package's binary + PackageArguments []Argument `json:"packageArguments,omitempty"` + // EnvironmentVariables are set when running the package EnvironmentVariables []KeyValueInput `json:"environmentVariables,omitempty"` }