Refactor package types to use dedicated schema types per package type (NPM, PyPI, NuGet, OCI, MCPB) (#634)
This commit is contained in:
parent
5055c79531
commit
632cbce815
18 changed files with 883 additions and 146 deletions
|
|
@ -267,36 +267,63 @@ func createServerJSON(
|
||||||
packageType, packageIdentifier, packageVersion string,
|
packageType, packageIdentifier, packageVersion string,
|
||||||
envVars []model.KeyValueInput,
|
envVars []model.KeyValueInput,
|
||||||
) apiv0.ServerJSON {
|
) apiv0.ServerJSON {
|
||||||
// Determine registry type and base URL
|
// Create package based on type
|
||||||
var registryType, registryBaseURL string
|
var pkg model.Package
|
||||||
|
|
||||||
switch packageType {
|
switch packageType {
|
||||||
case model.RegistryTypeNPM:
|
case model.RegistryTypeNPM:
|
||||||
registryType = model.RegistryTypeNPM
|
pkg = model.Package{
|
||||||
registryBaseURL = model.RegistryURLNPM
|
RegistryType: model.RegistryTypeNPM,
|
||||||
|
Identifier: packageIdentifier,
|
||||||
|
Version: packageVersion,
|
||||||
|
EnvironmentVariables: envVars,
|
||||||
|
Transport: model.Transport{
|
||||||
|
Type: model.TransportTypeStdio,
|
||||||
|
},
|
||||||
|
}
|
||||||
case model.RegistryTypePyPI:
|
case model.RegistryTypePyPI:
|
||||||
registryType = model.RegistryTypePyPI
|
pkg = model.Package{
|
||||||
registryBaseURL = model.RegistryURLPyPI
|
RegistryType: model.RegistryTypePyPI,
|
||||||
|
Identifier: packageIdentifier,
|
||||||
|
Version: packageVersion,
|
||||||
|
EnvironmentVariables: envVars,
|
||||||
|
Transport: model.Transport{
|
||||||
|
Type: model.TransportTypeStdio,
|
||||||
|
},
|
||||||
|
}
|
||||||
case model.RegistryTypeOCI:
|
case model.RegistryTypeOCI:
|
||||||
registryType = model.RegistryTypeOCI
|
// OCI packages use canonical references: registry/namespace/image:tag
|
||||||
registryBaseURL = model.RegistryURLDocker
|
// 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":
|
case "url":
|
||||||
registryType = "url"
|
pkg = model.Package{
|
||||||
registryBaseURL = ""
|
RegistryType: "url",
|
||||||
|
Identifier: packageIdentifier,
|
||||||
|
Version: packageVersion,
|
||||||
|
EnvironmentVariables: envVars,
|
||||||
|
Transport: model.Transport{
|
||||||
|
Type: model.TransportTypeStdio,
|
||||||
|
},
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
registryType = packageType
|
pkg = model.Package{
|
||||||
registryBaseURL = ""
|
RegistryType: packageType,
|
||||||
}
|
Identifier: packageIdentifier,
|
||||||
|
Version: packageVersion,
|
||||||
// Create package
|
EnvironmentVariables: envVars,
|
||||||
pkg := model.Package{
|
Transport: model.Transport{
|
||||||
RegistryType: registryType,
|
Type: model.TransportTypeStdio,
|
||||||
RegistryBaseURL: registryBaseURL,
|
},
|
||||||
Identifier: packageIdentifier,
|
}
|
||||||
Version: packageVersion,
|
|
||||||
EnvironmentVariables: envVars,
|
|
||||||
Transport: model.Transport{
|
|
||||||
Type: model.TransportTypeStdio,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create server structure
|
// Create server structure
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@
|
||||||
{
|
{
|
||||||
"src": "https://airtable.com/images/favicon/favicon-32x32.png",
|
"src": "https://airtable.com/images/favicon/favicon-32x32.png",
|
||||||
"mimeType": "image/png",
|
"mimeType": "image/png",
|
||||||
"sizes": ["32x32"]
|
"sizes": [
|
||||||
|
"32x32"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages": [
|
"packages": [
|
||||||
|
|
@ -35,8 +37,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"registryType": "oci",
|
"registryType": "oci",
|
||||||
"identifier": "domdomegg/airtable-mcp-server",
|
"identifier": "docker.io/domdomegg/airtable-mcp-server:1.7.2",
|
||||||
"version": "1.7.2",
|
|
||||||
"runtimeHint": "docker",
|
"runtimeHint": "docker",
|
||||||
"transport": {
|
"transport": {
|
||||||
"type": "stdio"
|
"type": "stdio"
|
||||||
|
|
@ -53,7 +54,6 @@
|
||||||
{
|
{
|
||||||
"registryType": "mcpb",
|
"registryType": "mcpb",
|
||||||
"identifier": "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb",
|
"identifier": "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb",
|
||||||
"version": "1.7.2",
|
|
||||||
"fileSha256": "8220de07a08ebe908f04da139ea03dbfe29758141347e945da60535fb7bcca20",
|
"fileSha256": "8220de07a08ebe908f04da139ea03dbfe29758141347e945da60535fb7bcca20",
|
||||||
"transport": {
|
"transport": {
|
||||||
"type": "stdio"
|
"type": "stdio"
|
||||||
|
|
@ -90,8 +90,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"registryType": "oci",
|
"registryType": "oci",
|
||||||
"identifier": "domdomegg/airtable-mcp-server",
|
"identifier": "docker.io/domdomegg/airtable-mcp-server:1.7.3",
|
||||||
"version": "1.7.3",
|
|
||||||
"runtimeHint": "docker",
|
"runtimeHint": "docker",
|
||||||
"transport": {
|
"transport": {
|
||||||
"type": "stdio"
|
"type": "stdio"
|
||||||
|
|
@ -108,7 +107,6 @@
|
||||||
{
|
{
|
||||||
"registryType": "mcpb",
|
"registryType": "mcpb",
|
||||||
"identifier": "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.3/airtable-mcp-server.mcpb",
|
"identifier": "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.3/airtable-mcp-server.mcpb",
|
||||||
"version": "1.7.3",
|
|
||||||
"fileSha256": "0f28a9129cfebd262dfb77854c872355d21401bb3e056575b3027081f5d570ca",
|
"fileSha256": "0f28a9129cfebd262dfb77854c872355d21401bb3e056575b3027081f5d570ca",
|
||||||
"transport": {
|
"transport": {
|
||||||
"type": "stdio"
|
"type": "stdio"
|
||||||
|
|
|
||||||
|
|
@ -270,7 +270,6 @@ components:
|
||||||
required:
|
required:
|
||||||
- registryType
|
- registryType
|
||||||
- identifier
|
- identifier
|
||||||
- version
|
|
||||||
- transport
|
- transport
|
||||||
properties:
|
properties:
|
||||||
registryType:
|
registryType:
|
||||||
|
|
|
||||||
|
|
@ -191,9 +191,7 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"registryType": "oci",
|
"registryType": "oci",
|
||||||
"registryBaseUrl": "https://docker.io",
|
"identifier": "docker.io/mcp/filesystem:1.0.2",
|
||||||
"identifier": "mcp/filesystem",
|
|
||||||
"version": "1.0.2",
|
|
||||||
"transport": {
|
"transport": {
|
||||||
"type": "stdio"
|
"type": "stdio"
|
||||||
},
|
},
|
||||||
|
|
@ -405,9 +403,7 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"registryType": "oci",
|
"registryType": "oci",
|
||||||
"registryBaseUrl": "https://docker.io",
|
"identifier": "docker.io/example/database-manager-mcp:3.1.0",
|
||||||
"identifier": "example/database-manager-mcp",
|
|
||||||
"version": "3.1.0",
|
|
||||||
"transport": {
|
"transport": {
|
||||||
"type": "stdio"
|
"type": "stdio"
|
||||||
},
|
},
|
||||||
|
|
@ -602,9 +598,7 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"registryType": "mcpb",
|
"registryType": "mcpb",
|
||||||
"registryBaseUrl": "https://github.com",
|
|
||||||
"identifier": "https://github.com/modelcontextprotocol/text-editor-mcpb/releases/download/v1.0.2/text-editor.mcpb",
|
"identifier": "https://github.com/modelcontextprotocol/text-editor-mcpb/releases/download/v1.0.2/text-editor.mcpb",
|
||||||
"version": "1.0.2",
|
|
||||||
"fileSha256": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce",
|
"fileSha256": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce",
|
||||||
"transport": {
|
"transport": {
|
||||||
"type": "stdio"
|
"type": "stdio"
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,6 @@
|
||||||
"required": [
|
"required": [
|
||||||
"registryType",
|
"registryType",
|
||||||
"identifier",
|
"identifier",
|
||||||
"version",
|
|
||||||
"transport"
|
"transport"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,6 @@ func TestPublishRegistryValidation(t *testing.T) {
|
||||||
{
|
{
|
||||||
RegistryType: model.RegistryTypeMCPB,
|
RegistryType: model.RegistryTypeMCPB,
|
||||||
Identifier: "https://github.com/microsoft/playwright-mcp/releases/download/v0.0.36/playwright-mcp-extension-v0.0.36.zip",
|
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",
|
FileSHA256: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce",
|
||||||
Transport: model.Transport{
|
Transport: model.Transport{
|
||||||
Type: model.TransportTypeStdio,
|
Type: model.TransportTypeStdio,
|
||||||
|
|
@ -146,7 +145,6 @@ func TestPublishRegistryValidation(t *testing.T) {
|
||||||
{
|
{
|
||||||
RegistryType: model.RegistryTypeMCPB,
|
RegistryType: model.RegistryTypeMCPB,
|
||||||
Identifier: "https://github.com/microsoft/playwright-mcp/releases/download/v0.0.36/playwright-mcp-extension-v0.0.36.zip",
|
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",
|
FileSHA256: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce",
|
||||||
Transport: model.Transport{
|
Transport: model.Transport{
|
||||||
Type: model.TransportTypeStdio,
|
Type: model.TransportTypeStdio,
|
||||||
|
|
@ -206,7 +204,6 @@ func TestPublishRegistryValidation(t *testing.T) {
|
||||||
{
|
{
|
||||||
RegistryType: model.RegistryTypeMCPB,
|
RegistryType: model.RegistryTypeMCPB,
|
||||||
Identifier: "https://github.com/microsoft/playwright-mcp/releases/download/v0.0.36/playwright-mcp-extension-v0.0.36.zip",
|
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",
|
FileSHA256: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce",
|
||||||
Transport: model.Transport{
|
Transport: model.Transport{
|
||||||
Type: model.TransportTypeStdio,
|
Type: model.TransportTypeStdio,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -27,23 +27,20 @@ func ValidateMCPB(ctx context.Context, pkg model.Package, _ string) error {
|
||||||
return ErrMissingIdentifierForMCPB
|
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)
|
err := validateMCPBUrl(pkg.Identifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// Parse the URL to validate format
|
||||||
url, err := url.Parse(pkg.Identifier)
|
url, err := url.Parse(pkg.Identifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -161,21 +158,3 @@ func isValidGitLabReleaseURL(path string) bool {
|
||||||
|
|
||||||
return false
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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'")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ func ValidateNPM(ctx context.Context, pkg model.Package, serverName string) erro
|
||||||
return ErrMissingVersionForNPM
|
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
|
// Validate that the registry base URL matches NPM exactly
|
||||||
if pkg.RegistryBaseURL != model.RegistryURLNPM {
|
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",
|
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ func ValidateNuGet(ctx context.Context, pkg model.Package, serverName string) er
|
||||||
return ErrMissingIdentifierForNuget
|
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
|
// Validate that the registry base URL matches NuGet exactly
|
||||||
if pkg.RegistryBaseURL != model.RegistryURLNuGet {
|
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",
|
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/registry/pkg/model"
|
"github.com/modelcontextprotocol/registry/pkg/model"
|
||||||
|
|
@ -78,60 +77,73 @@ type OCIImageConfig struct {
|
||||||
} `json:"config"`
|
} `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 {
|
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 == "" {
|
if pkg.Identifier == "" {
|
||||||
return ErrMissingIdentifierForOCI
|
return ErrMissingIdentifierForOCI
|
||||||
}
|
}
|
||||||
|
|
||||||
// we need version (tag) to look up the image manifest
|
// Validate that old format fields are not present
|
||||||
if pkg.Version == "" {
|
if pkg.RegistryBaseURL != "" {
|
||||||
return ErrMissingVersionForOCI
|
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
|
// Parse the canonical OCI reference from the identifier
|
||||||
if err := validateRegistryURL(pkg.RegistryBaseURL); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
// Parse image reference (namespace/repo or repo)
|
// Get registry configuration
|
||||||
namespace, repo, err := parseImageReference(pkg.Identifier)
|
registryConfig := getRegistryConfig(registryBaseURL, ociRef.Namespace, ociRef.Image)
|
||||||
if err != nil {
|
if registryConfig == nil {
|
||||||
return fmt.Errorf("invalid OCI image reference: %w", err)
|
return fmt.Errorf("unsupported registry: %s", registryBaseURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get registry configuration
|
// Determine what to use for manifest lookup: digest if available (most secure), otherwise tag
|
||||||
registryConfig := getRegistryConfig(pkg.RegistryBaseURL, namespace, repo)
|
manifestRef := ociRef.Tag
|
||||||
if registryConfig == nil {
|
if ociRef.Digest != "" {
|
||||||
return fmt.Errorf("unsupported registry: %s", pkg.RegistryBaseURL)
|
manifestRef = ociRef.Digest
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the image manifest
|
// 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 {
|
if err != nil {
|
||||||
// Handle rate limiting explicitly - skip validation
|
// Handle rate limiting explicitly - skip validation
|
||||||
if errors.Is(err, ErrRateLimited) {
|
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 nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get config digest from manifest
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate server name annotation
|
// 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
|
// validateRegistryURL validates that the registry base URL is supported
|
||||||
|
|
@ -229,18 +241,6 @@ func validateServerNameAnnotation(ctx context.Context, client *http.Client, regi
|
||||||
return nil
|
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
|
// getRegistryAuthToken retrieves an authentication token from a registry
|
||||||
func getRegistryAuthToken(ctx context.Context, client *http.Client, config *RegistryConfig) (string, error) {
|
func getRegistryAuthToken(ctx context.Context, client *http.Client, config *RegistryConfig) (string, error) {
|
||||||
if config.AuthURL == "" {
|
if config.AuthURL == "" {
|
||||||
|
|
|
||||||
161
internal/validators/registries/oci_ref_parser.go
Normal file
161
internal/validators/registries/oci_ref_parser.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
229
internal/validators/registries/oci_ref_parser_test.go
Normal file
229
internal/validators/registries/oci_ref_parser_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -138,11 +138,10 @@ func TestValidateOCI_RealPackages(t *testing.T) {
|
||||||
func TestValidateOCI_UnsupportedRegistry(t *testing.T) {
|
func TestValidateOCI_UnsupportedRegistry(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test with unsupported registry in canonical reference format
|
||||||
pkg := model.Package{
|
pkg := model.Package{
|
||||||
RegistryType: model.RegistryTypeOCI,
|
RegistryType: model.RegistryTypeOCI,
|
||||||
RegistryBaseURL: "https://unsupported-registry.com",
|
Identifier: "unsupported-registry.com/test/image:latest",
|
||||||
Identifier: "test/image",
|
|
||||||
Version: "latest",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := registries.ValidateOCI(ctx, pkg, "com.example/test")
|
err := registries.ValidateOCI(ctx, pkg, "com.example/test")
|
||||||
|
|
@ -155,34 +154,32 @@ func TestValidateOCI_SupportedRegistries(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
registryURL string
|
identifier string
|
||||||
expected bool
|
expected bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Docker Hub should be supported",
|
name: "Docker Hub should be supported",
|
||||||
registryURL: model.RegistryURLDocker,
|
identifier: "docker.io/test/image:latest",
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "GHCR should be supported",
|
name: "GHCR should be supported",
|
||||||
registryURL: model.RegistryURLGHCR,
|
identifier: "ghcr.io/test/image:latest",
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Unsupported registry should fail",
|
name: "Unsupported registry should fail",
|
||||||
registryURL: "https://quay.io",
|
identifier: "quay.io/test/image:latest",
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
pkg := model.Package{
|
pkg := model.Package{
|
||||||
RegistryType: model.RegistryTypeOCI,
|
RegistryType: model.RegistryTypeOCI,
|
||||||
RegistryBaseURL: tt.registryURL,
|
Identifier: tt.identifier,
|
||||||
Identifier: "test/image",
|
|
||||||
Version: "latest",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := registries.ValidateOCI(ctx, pkg, "com.example/test")
|
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'")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ func ValidatePyPI(ctx context.Context, pkg model.Package, serverName string) err
|
||||||
return ErrMissingVersionForPyPi
|
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
|
// Validate that the registry base URL matches PyPI exactly
|
||||||
if pkg.RegistryBaseURL != model.RegistryURLPyPI {
|
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",
|
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
|
||||||
|
|
|
||||||
|
|
@ -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_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, 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_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, model.RegistryURLNuGet, "TimeMcpServer", "1.0.2", "", false},
|
||||||
{"valid_nuget", "io.github.domdomegg/time-mcp-server", model.RegistryTypeNuGet, "", "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", "", "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, "", "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
|
||||||
{"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
|
|
||||||
|
|
||||||
// Test MCPB without file hash (should fail)
|
// 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 registry types (should fail)
|
||||||
{"invalid_maven", "io.github.domdomegg/airtable-mcp-server", "maven", model.RegistryURLNPM, "airtable-mcp-server", "1.7.2", "", true},
|
{"invalid_maven", "io.github.domdomegg/airtable-mcp-server", "maven", model.RegistryURLNPM, "airtable-mcp-server", "1.7.2", "", true},
|
||||||
|
|
|
||||||
|
|
@ -16,20 +16,36 @@ type Transport struct {
|
||||||
Headers []KeyValueInput `json:"headers,omitempty"`
|
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 {
|
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"`
|
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"`
|
RegistryBaseURL string `json:"registryBaseUrl,omitempty"`
|
||||||
// Identifier is the package identifier - either a package name (for registries) or URL (for direct downloads)
|
// Identifier is the package identifier:
|
||||||
Identifier string `json:"identifier" minLength:"1"`
|
// - For NPM/PyPI/NuGet: package name or ID
|
||||||
Version string `json:"version" minLength:"1"`
|
// - For OCI: full image reference (e.g., "ghcr.io/owner/repo:v1.0.0")
|
||||||
FileSHA256 string `json:"fileSha256,omitempty"`
|
// - For MCPB: direct download URL
|
||||||
RunTimeHint string `json:"runtimeHint,omitempty"`
|
Identifier string `json:"identifier" minLength:"1"`
|
||||||
Transport Transport `json:"transport,omitempty"`
|
// Version is the package version (used by npm, pypi, nuget; not used by oci, mcpb where version is in the identifier)
|
||||||
RuntimeArguments []Argument `json:"runtimeArguments,omitempty"`
|
Version string `json:"version,omitempty" minLength:"1"`
|
||||||
PackageArguments []Argument `json:"packageArguments,omitempty"`
|
// 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"`
|
EnvironmentVariables []KeyValueInput `json:"environmentVariables,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue