diff --git a/go.mod b/go.mod index c0cefb5..a8acbfb 100644 --- a/go.mod +++ b/go.mod @@ -17,13 +17,25 @@ require ( require ( cel.dev/expr v0.25.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mark3labs/mcp-go v0.44.0 // indirect + github.com/modelcontextprotocol/registry v1.4.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/go.sum b/go.sum index e8990e9..70ad84c 100644 --- a/go.sum +++ b/go.sum @@ -4,20 +4,48 @@ github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I= +github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/modelcontextprotocol/registry v1.4.0 h1:k07g5UPubxonZ627B/da+e3oCkFr9DLoYqaxa8aawwU= +github.com/modelcontextprotocol/registry v1.4.0/go.mod h1:50oU8Q6ecBwVoH7G6e3KJwdhj0ShBH1Xmfvz8kst5AU= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -34,8 +62,12 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs= google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/permissions/profile.go b/permissions/profile.go new file mode 100644 index 0000000..82a9d6c --- /dev/null +++ b/permissions/profile.go @@ -0,0 +1,492 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package permissions provides types and utilities for managing container +// permissions and permission profiles in the ToolHive ecosystem. +package permissions + +import ( + "encoding/json" + "fmt" + "os" + pkgpath "path" + "path/filepath" + "regexp" + "strings" +) + +// Built-in permission profile names +const ( + // ProfileNone is the name of the built-in profile with no permissions + ProfileNone = "none" + // ProfileNetwork is the name of the built-in profile with network permissions + ProfileNetwork = "network" +) + +// Profile represents a permission profile for a container +type Profile struct { + // Name is the name of the profile + Name string `json:"name,omitempty" yaml:"name,omitempty"` + + // Read is a list of mount declarations that the container can read from + // These can be in the following formats: + // - A single path: The same path will be mounted from host to container + // - host-path:container-path: Different paths for host and container + // - resource-uri:container-path: Mount a resource identified by URI to a container path + Read []MountDeclaration `json:"read,omitempty" yaml:"read,omitempty"` + + // Write is a list of mount declarations that the container can write to + // These follow the same format as Read mounts but with write permissions + Write []MountDeclaration `json:"write,omitempty" yaml:"write,omitempty"` + + // Network defines network permissions + Network *NetworkPermissions `json:"network,omitempty" yaml:"network,omitempty"` + + // Privileged indicates whether the container should run in privileged mode + // When true, the container has access to all host devices and capabilities + // Use with extreme caution as this removes most security isolation + Privileged bool `json:"privileged,omitempty" yaml:"privileged,omitempty"` +} + +// NetworkPermissions defines network permissions for a container +type NetworkPermissions struct { + // Mode specifies the network mode for the container (e.g., "host", "bridge", "none") + // When empty, the default container runtime network mode is used + Mode string `json:"mode,omitempty" yaml:"mode,omitempty"` + + // Outbound defines outbound network permissions + Outbound *OutboundNetworkPermissions `json:"outbound,omitempty" yaml:"outbound,omitempty"` + + // Inbound defines inbound network permissions + Inbound *InboundNetworkPermissions `json:"inbound,omitempty" yaml:"inbound,omitempty"` +} + +// OutboundNetworkPermissions defines outbound network permissions +type OutboundNetworkPermissions struct { + // InsecureAllowAll allows all outbound network connections + InsecureAllowAll bool `json:"insecure_allow_all,omitempty" yaml:"insecure_allow_all,omitempty"` + + // AllowHost is a list of allowed hosts + AllowHost []string `json:"allow_host,omitempty" yaml:"allow_host,omitempty"` + + // AllowPort is a list of allowed ports + AllowPort []int `json:"allow_port,omitempty" yaml:"allow_port,omitempty"` +} + +// InboundNetworkPermissions defines inbound network permissions +type InboundNetworkPermissions struct { + // AllowHost is a list of allowed hosts for inbound connections + AllowHost []string `json:"allow_host,omitempty" yaml:"allow_host,omitempty"` +} + +// NewProfile creates a new permission profile +func NewProfile() *Profile { + return BuiltinNoneProfile() +} + +// FromFile loads a permission profile from a file +func FromFile(path string) (*Profile, error) { + // Read the file + // #nosec G304 - This is intentional as we're reading a user-specified permission profile + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read permission profile: %w", err) + } + + // Parse the JSON + var profile Profile + if err := json.Unmarshal(data, &profile); err != nil { + return nil, fmt.Errorf("failed to parse permission profile: %w", err) + } + + return &profile, nil +} + +// BuiltinNoneProfile returns the built-in profile with no permissions +func BuiltinNoneProfile() *Profile { + return &Profile{ + Name: ProfileNone, + Read: []MountDeclaration{}, + Write: []MountDeclaration{}, + Network: &NetworkPermissions{ + Outbound: &OutboundNetworkPermissions{ + InsecureAllowAll: false, + AllowHost: []string{}, + AllowPort: []int{}, + }, + Inbound: &InboundNetworkPermissions{ + AllowHost: []string{}, + }, + }, + Privileged: false, + } +} + +// BuiltinNetworkProfile returns the built-in network profile +func BuiltinNetworkProfile() *Profile { + return &Profile{ + Name: ProfileNetwork, + Read: []MountDeclaration{}, + Write: []MountDeclaration{}, + Network: &NetworkPermissions{ + Outbound: &OutboundNetworkPermissions{ + InsecureAllowAll: true, + AllowHost: []string{}, + AllowPort: []int{}, + }, + Inbound: &InboundNetworkPermissions{ + AllowHost: []string{}, + }, + }, + Privileged: false, + } +} + +// MountDeclaration represents a mount declaration for a container +// It can be in one of the following formats: +// - A single path: The same path will be mounted from host to container +// - host-path:container-path: Different paths for host and container +// - resource-uri:container-path: Mount a resource identified by URI to a container path +// (e.g., volume://name:container-path) +type MountDeclaration string + +// Regular expressions for parsing mount declarations +var ( + // windowsPathRegex matches Windows-style paths with drive letters + // Matches patterns like C:, D:, etc. at the start of a path + windowsPathRegex = regexp.MustCompile(`^[a-zA-Z]:[/\\]`) + + // commandInjectionPattern matches common command injection patterns + commandInjectionPattern = regexp.MustCompile(`[$&;|]|\$\(|\` + "`") + + // resourceSchemeRegex validates resource URI schemes + resourceSchemeRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`) +) + +// validatePath checks if a path contains potentially dangerous patterns +func validatePath(path string) error { + if commandInjectionPattern.MatchString(path) { + return fmt.Errorf("potential command injection detected in path: %s", path) + } + + // Check for null bytes + if strings.Contains(path, "\x00") { + return fmt.Errorf("null byte detected in path: %s", path) + } + + return nil +} + +// isWindowsPath checks if a path appears to be a Windows path +func isWindowsPath(path string) bool { + // Match full Windows paths with drive letters (C:\path or C:/path) + if windowsPathRegex.MatchString(path) { + return true + } + // Also match paths that start with backslashes (could be Windows UNC or fragment) + if strings.HasPrefix(path, "\\") { + return true + } + return false +} + +// validateResourceScheme checks if a resource URI scheme is valid +func validateResourceScheme(scheme string) bool { + return resourceSchemeRegex.MatchString(scheme) +} + +// isValidContainerPath checks if a path looks like a valid container path +func isValidContainerPath(path string) bool { + // Container paths can be: + // 1. Unix-style paths starting with / + // 2. Windows paths (which we'll reject later, but they're valid format) + // 3. Relative paths (no colons) + return strings.HasPrefix(path, "/") || + isWindowsPath(path) || + (path != "" && !strings.Contains(path, ":")) +} + +// findResourceURISeparator finds the colon separator between resource name and container path +func findResourceURISeparator(remainder string) int { + colonPositions := findColonPositions(remainder) + + if len(colonPositions) == 0 { + return -1 // No separator colon found + } + + // Try each colon position from right to left to find the separator + for i := len(colonPositions) - 1; i >= 0; i-- { + colonIdx := colonPositions[i] + if colonIdx+1 < len(remainder) { + possibleContainerPath := remainder[colonIdx+1:] + if isValidContainerPath(possibleContainerPath) { + return colonIdx + } + } + } + + return -1 // No valid separator found +} + +// splitResourceURI splits a resource URI declaration into scheme and remainder +func splitResourceURI(declaration string) (scheme, remainder string, valid bool) { + // Check if it starts like a resource URI (scheme://) + if !strings.Contains(declaration, "://") { + return "", "", false // Not a resource URI + } + + // Split on :// to get scheme and remainder + schemeParts := strings.SplitN(declaration, "://", 2) + if len(schemeParts) != 2 { + return "", "", false // Not a valid resource URI format + } + + scheme = schemeParts[0] + remainder = schemeParts[1] + + // Validate scheme format + if !validateResourceScheme(scheme) { + return "", "", false // Invalid scheme + } + + return scheme, remainder, true +} + +// parseResourceURI parses a resource URI format (scheme://resource:container-path) +func parseResourceURI(declaration string) (source, target string, err error) { + scheme, remainder, valid := splitResourceURI(declaration) + if !valid { + return "", "", nil // Not a valid resource URI + } + + separatorIdx := findResourceURISeparator(remainder) + if separatorIdx == -1 { + return "", "", nil // No valid separator found + } + + resourceName := remainder[:separatorIdx] + containerPath := remainder[separatorIdx+1:] + + // Both parts should be non-empty + if resourceName == "" || containerPath == "" { + return "", "", nil // Invalid format + } + + // Reject Windows paths in container/target path + if isWindowsPath(containerPath) { + return "", "", fmt.Errorf("windows paths are not allowed as container paths: %s", containerPath) + } + + // Validate paths + if err := validatePath(resourceName); err != nil { + return "", "", err + } + if err := validatePath(containerPath); err != nil { + return "", "", err + } + + // Clean paths + cleanedResource := filepath.Clean(resourceName) + // For the target, we explicitly use path.Clean so that we do not convert + // Unix style paths into Windows style paths on Windows hosts + cleanedTarget := pkgpath.Clean(containerPath) + + return scheme + "://" + cleanedResource, cleanedTarget, nil +} + +// findColonPositions returns all positions of colons in the string +func findColonPositions(s string) []int { + positions := []int{} + for i, r := range s { + if r == ':' { + positions = append(positions, i) + } + } + return positions +} + +// parseWindowsPath handles Windows-style path parsing +func parseWindowsPath(declaration string, colonPositions []int) (source, target string, err error) { + // If there's only one colon and it's at position 1 (drive letter), + // treat this as a single path + if len(colonPositions) == 1 && colonPositions[0] == 1 { + if err := validatePath(declaration); err != nil { + return "", "", err + } + cleanedPath := filepath.Clean(declaration) + return cleanedPath, cleanedPath, nil + } + + // If there are exactly two colons, and the first is at position 1 (drive letter), + // then the second one should be the separator + if len(colonPositions) == 2 && colonPositions[0] == 1 { + hostPath := declaration[:colonPositions[1]] + containerPath := declaration[colonPositions[1]+1:] + + // Reject Windows paths in container/target path + if isWindowsPath(containerPath) { + return "", "", fmt.Errorf("windows paths are not allowed as container paths: %s", containerPath) + } + + if err := validatePath(hostPath); err != nil { + return "", "", err + } + if err := validatePath(containerPath); err != nil { + return "", "", err + } + + cleanedSource := filepath.Clean(hostPath) + // See comment above about using path.Clean instead of filepath.Clean. + cleanedTarget := pkgpath.Clean(containerPath) + return cleanedSource, cleanedTarget, nil + } + + // If there are more than 2 colons and the first is at position 1, + // this is ambiguous and should be treated as invalid + if len(colonPositions) > 2 && colonPositions[0] == 1 { + return "", "", fmt.Errorf("invalid mount declaration format: %s "+ + "(Windows paths with multiple colons are ambiguous)", declaration) + } + + return "", "", nil // Not handled by Windows path logic +} + +// parseHostContainerPath handles host:container path parsing for non-Windows paths +func parseHostContainerPath(declaration string, colonPositions []int) (source, target string, err error) { + // For non-Windows paths: if there's exactly one colon, treat as host:container + if len(colonPositions) == 1 { + colonIdx := colonPositions[0] + hostPath := declaration[:colonIdx] + containerPath := declaration[colonIdx+1:] + + // Reject Windows paths in container/target path + if isWindowsPath(containerPath) { + return "", "", fmt.Errorf("windows paths are not allowed as container paths: %s", containerPath) + } + + if err := validatePath(hostPath); err != nil { + return "", "", err + } + if err := validatePath(containerPath); err != nil { + return "", "", err + } + + cleanedSource := filepath.Clean(hostPath) + // See comment above about using path.Clean instead of filepath.Clean. + cleanedTarget := pkgpath.Clean(containerPath) + return cleanedSource, cleanedTarget, nil + } + + // Multiple colons in non-Windows paths are invalid + if len(colonPositions) > 1 { + return "", "", fmt.Errorf("invalid mount declaration format: %s "+ + "(multiple colons found, expected single colon separator)", declaration) + } + + return "", "", nil // Not handled +} + +// parseSinglePath handles single path declarations (no colons) +func parseSinglePath(declaration string) (source, target string, err error) { + if err := validatePath(declaration); err != nil { + return "", "", err + } + + // Single path should always be converted to OS-specific cleaned path. + cleanedPath := filepath.Clean(declaration) + return cleanedPath, cleanedPath, nil +} + +// Parse parses a mount declaration and returns the source and target paths +// It also cleans and validates the paths +func (m MountDeclaration) Parse() (source, target string, err error) { + declaration := string(m) + + // Check if it's a resource URI + if source, target, err := parseResourceURI(declaration); err != nil { + return "", "", err + } else if source != "" { + return source, target, nil + } + + // Check if it contains a colon for host:container format + if strings.Contains(declaration, ":") { + colonPositions := findColonPositions(declaration) + + // Special case: Windows path handling + if windowsPathRegex.MatchString(declaration) { + if source, target, err := parseWindowsPath(declaration, colonPositions); err != nil { + return "", "", err + } else if source != "" { + return source, target, nil + } + } + + // Handle non-Windows host:container paths + if source, target, err := parseHostContainerPath(declaration, colonPositions); err != nil { + return "", "", err + } else if source != "" { + return source, target, nil + } + } + + // If it doesn't contain a colon, it's a single path + if !strings.Contains(declaration, ":") { + return parseSinglePath(declaration) + } + + // If we get here, the format is invalid + return "", "", fmt.Errorf("invalid mount declaration format: %s "+ + "(expected path, host-path:container-path, or scheme://resource:container-path)", declaration) +} + +// IsValid checks if the mount declaration is valid +func (m MountDeclaration) IsValid() bool { + _, _, err := m.Parse() + return err == nil +} + +// IsResourceURI checks if the mount declaration is a resource URI format +// This only checks the format, not the security of the paths +func (m MountDeclaration) IsResourceURI() bool { + _, remainder, valid := splitResourceURI(string(m)) + if !valid { + return false + } + separatorIdx := findResourceURISeparator(remainder) + if separatorIdx == -1 { + return false + } + resourceName := remainder[:separatorIdx] + containerPath := remainder[separatorIdx+1:] + return resourceName != "" && containerPath != "" +} + +// GetResourceType returns the resource type if the mount declaration is a resource URI +// For example, "volume://name" would return "volume" +func (m MountDeclaration) GetResourceType() (string, error) { + if !m.IsResourceURI() { + return "", fmt.Errorf("not a resource URI: %s", m) + } + + declaration := string(m) + + // Split on :// to get scheme (we know it's valid because IsResourceURI passed) + schemeParts := strings.SplitN(declaration, "://", 2) + return schemeParts[0], nil +} + +// ParseMountDeclarations parses a list of mount declarations +func ParseMountDeclarations(declarations []string) ([]MountDeclaration, error) { + result := make([]MountDeclaration, 0, len(declarations)) + + for _, declaration := range declarations { + mount := MountDeclaration(declaration) + if _, _, err := mount.Parse(); err != nil { + return nil, fmt.Errorf("invalid mount declaration: %s (%w)", declaration, err) + } + result = append(result, mount) + } + + return result, nil +} diff --git a/permissions/profile_privileged_test.go b/permissions/profile_privileged_test.go new file mode 100644 index 0000000..21f79bc --- /dev/null +++ b/permissions/profile_privileged_test.go @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package permissions + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProfile_Privileged(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + profile *Profile + expected bool + }{ + { + name: "Default profile should not be privileged", + profile: &Profile{ + Name: "test", + Privileged: false, + }, + expected: false, + }, + { + name: "Privileged profile should be privileged", + profile: &Profile{ + Name: "test", + Privileged: true, + }, + expected: true, + }, + { + name: "Built-in none profile should not be privileged", + profile: BuiltinNoneProfile(), + expected: false, + }, + { + name: "Built-in network profile should not be privileged", + profile: BuiltinNetworkProfile(), + expected: false, + }, + { + name: "New profile should not be privileged by default", + profile: NewProfile(), + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, tc.profile.Privileged, "Privileged flag should match expected value") + }) + } +} + +func TestProfile_PrivilegedJSONSerialization(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + profile *Profile + expected string + }{ + { + name: "Non-privileged profile JSON", + profile: &Profile{ + Name: "test", + Privileged: false, + }, + expected: `{"name":"test"}`, // privileged: false should be omitted due to omitempty + }, + { + name: "Privileged profile JSON", + profile: &Profile{ + Name: "test", + Privileged: true, + }, + expected: `{"name":"test","privileged":true}`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Test marshaling + jsonData, err := json.Marshal(tc.profile) + require.NoError(t, err, "JSON marshaling should not fail") + assert.JSONEq(t, tc.expected, string(jsonData), "JSON output should match expected") + + // Test unmarshaling + var unmarshaled Profile + err = json.Unmarshal(jsonData, &unmarshaled) + require.NoError(t, err, "JSON unmarshaling should not fail") + assert.Equal(t, tc.profile.Privileged, unmarshaled.Privileged, "Privileged flag should be preserved after JSON round-trip") + }) + } +} + +func TestProfile_PrivilegedYAMLSerialization(t *testing.T) { + t.Parallel() + + // Test that the YAML tag is present and works + profile := &Profile{ + Name: "test", + Privileged: true, + } + + // Test JSON marshaling (which should work with YAML tags too) + jsonData, err := json.Marshal(profile) + require.NoError(t, err, "JSON marshaling should not fail") + + var unmarshaled Profile + err = json.Unmarshal(jsonData, &unmarshaled) + require.NoError(t, err, "JSON unmarshaling should not fail") + + assert.Equal(t, profile.Privileged, unmarshaled.Privileged, "Privileged flag should be preserved") + assert.Equal(t, profile.Name, unmarshaled.Name, "Name should be preserved") +} diff --git a/permissions/profile_test.go b/permissions/profile_test.go new file mode 100644 index 0000000..81369ad --- /dev/null +++ b/permissions/profile_test.go @@ -0,0 +1,477 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package permissions + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMountDeclaration_Parse(t *testing.T) { + t.Parallel() + tests := []struct { + name string + declaration MountDeclaration + expectedSource string + expectedTarget string + expectError bool + }{ + { + name: "Single path", + declaration: "/path/to/dir", + expectedSource: "/path/to/dir", + expectedTarget: "/path/to/dir", + expectError: false, + }, + { + // In Docker, a single Windows path gets mapped to a subdirectory + // of root with the name of the Windows path. + // e.g. C:\foo -> /C:\\foo + // While this behaviour is unusual, it's valid, and we should support it. + name: "Single path (Windows)", + declaration: "C:\\foo\\bar", + expectedSource: "C:\\foo\\bar", + expectedTarget: "C:\\foo\\bar", + expectError: false, + }, + { + name: "Host path to container path", + declaration: "/host/path:/container/path", + expectedSource: "/host/path", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Resource URI", + declaration: "volume://myvolume:/container/path", + expectedSource: "volume://myvolume", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Resource URI (Windows)", + declaration: "volume://C:\\Foo\\Bar:/container/path", + expectedSource: "volume://C:\\Foo\\Bar", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Resource URI (Windows forward slashes)", + declaration: "volume://C:/Foo/Bar:/container/path", + expectedSource: "volume://C:/Foo/Bar", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Resource URI (Windows mixed slashes)", + declaration: "volume://C:\\Foo/Bar:/container/path", + expectedSource: "volume://C:\\Foo/Bar", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Different resource URI", + declaration: "secret://mysecret:/container/path", + expectedSource: "secret://mysecret", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Reject Resource URI with Windows target", + declaration: "volume://C:\\Foo\\Bar:C:\\container\\path", + expectedSource: "", + expectedTarget: "", + expectError: true, + }, + { + name: "Reject Resource URI with Windows source and target", + declaration: "volume://foo/bar:C:\\container\\path", + expectedSource: "", + expectedTarget: "", + expectError: true, + }, + { + name: "Reject Resource URI with backslashes in target", + declaration: "volume://C:\\Foo\\Bar:\\container\\path", + expectedSource: "", + expectedTarget: "", + expectError: true, + }, + // Security-focused tests + { + name: "Path with spaces", + declaration: "/path with spaces:/container/path", + expectedSource: "/path with spaces", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Path with special characters", + declaration: "/path/with/special/chars!@#:/container/path", + expectedSource: "/path/with/special/chars!@#", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Path with Unicode characters", + declaration: "/path/with/unicode/😀:/container/path", + expectedSource: "/path/with/unicode/😀", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Windows style path", + declaration: "C:\\path\\to\\dir:/container/path", + expectedSource: "C:\\path\\to\\dir", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Windows style path (forward slashes)", + declaration: "C:/path/to/dir:/container/path", + expectedSource: "C:/path/to/dir", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Windows style path (mixed slashes)", + declaration: "C:\\path/to\\dir:/container/path", // Yes, this is allowed on Windows... + expectedSource: "C:\\path/to\\dir", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Reject Windows style path for target", + declaration: "/foo/bar:C:\\container\\path", + expectedSource: "", + expectedTarget: "", + expectError: true, + }, + { + name: "Reject backslashes in target", + declaration: "/foo/bar:\\container\\path", + expectedSource: "", + expectedTarget: "", + expectError: true, + }, + { + name: "Reject Windows style path for source and target", + declaration: "C:\\path/to\\dir:C:\\container\\path", + expectedSource: "", + expectedTarget: "", + expectError: true, + }, + { + name: "Path with trailing slash", + declaration: "/path/to/dir/:/container/path/", + expectedSource: "/path/to/dir", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Path with multiple slashes", + declaration: "/path//to///dir:/container//path", + expectedSource: "/path/to/dir", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Path with Unicode characters", + declaration: "/path/with/unicode/😀:/container/path", + expectedSource: "/path/with/unicode/😀", + expectedTarget: "/container/path", + expectError: false, + }, + { + name: "Path with potential command injection", + declaration: "/path/with/$(rm -rf *):/container/path", + expectedSource: "", + expectedTarget: "", + expectError: true, // Now expecting an error due to validation + }, + { + name: "Path with potential path traversal", + declaration: "/path/with/../../../etc/passwd:/container/path", + expectedSource: "/etc/passwd", // filepath.Clean resolves the path + expectedTarget: "/container/path", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + source, target, err := tt.declaration.Parse() + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expectedSource, source) + assert.Equal(t, tt.expectedTarget, target) + }) + } +} + +func TestMountDeclaration_IsValid(t *testing.T) { + t.Parallel() + tests := []struct { + name string + declaration MountDeclaration + expected bool + }{ + { + name: "Single path", + declaration: "/path/to/dir", + expected: true, + }, + { + name: "Host path to container path", + declaration: "/host/path:/container/path", + expected: true, + }, + { + name: "Resource URI", + declaration: "volume://myvolume:/container/path", + expected: true, + }, + { + name: "Empty string", + declaration: "", + expected: true, // Empty string is treated as a single path + }, + // Security-focused tests + { + name: "Path with potential command injection", + declaration: "/path/with/$(rm -rf *):/container/path", + expected: false, // Now invalid due to validation + }, + { + name: "Path with potential path traversal", + declaration: "/path/with/../../../etc/passwd:/container/path", + expected: true, // Valid format, but potentially dangerous + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, tt.declaration.IsValid()) + }) + } +} + +func TestMountDeclaration_IsResourceURI(t *testing.T) { + t.Parallel() + tests := []struct { + name string + declaration MountDeclaration + expected bool + }{ + { + name: "Single path", + declaration: "/path/to/dir", + expected: false, + }, + { + name: "Host path to container path", + declaration: "/host/path:/container/path", + expected: false, + }, + { + name: "Resource URI", + declaration: "volume://myvolume:/container/path", + expected: true, + }, + { + name: "Different resource URI", + declaration: "secret://mysecret:/container/path", + expected: true, + }, + // Security-focused tests + { + name: "Malformed resource URI", + declaration: "volume:/myvolume:/container/path", // Missing a slash + expected: false, + }, + { + name: "Resource URI with potential command injection", + declaration: "volume://$(rm -rf *):/container/path", + expected: true, // Valid format, but potentially dangerous + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, tt.declaration.IsResourceURI()) + }) + } +} + +func TestMountDeclaration_GetResourceType(t *testing.T) { + t.Parallel() + tests := []struct { + name string + declaration MountDeclaration + expectedType string + expectError bool + }{ + { + name: "Single path", + declaration: "/path/to/dir", + expectedType: "", + expectError: true, + }, + { + name: "Host path to container path", + declaration: "/host/path:/container/path", + expectedType: "", + expectError: true, + }, + { + name: "Volume resource URI", + declaration: "volume://myvolume:/container/path", + expectedType: "volume", + expectError: false, + }, + { + name: "Secret resource URI", + declaration: "secret://mysecret:/container/path", + expectedType: "secret", + expectError: false, + }, + // Security-focused tests + { + name: "Resource URI with potential command injection", + declaration: "volume://$(rm -rf *):/container/path", + expectedType: "volume", + expectError: false, // Valid format, but potentially dangerous + }, + { + name: "Resource URI with unusual scheme", + declaration: "file://etc/passwd:/container/path", + expectedType: "file", + expectError: false, // Valid format, but potentially dangerous + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + resourceType, err := tt.declaration.GetResourceType() + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expectedType, resourceType) + }) + } +} + +func TestParseMountDeclarations(t *testing.T) { + t.Parallel() + tests := []struct { + name string + declarations []string + expectError bool + }{ + { + name: "Valid declarations", + declarations: []string{ + "/path/to/dir", + "/host/path:/container/path", + "volume://myvolume:/container/path", + }, + expectError: false, + }, + { + name: "Empty list", + declarations: []string{}, + expectError: false, + }, + // Security-focused tests + { + name: "Declarations with potential security issues", + declarations: []string{ + "/path/with/$(rm -rf *):/container/path", + "volume://$(rm -rf *):/container/path", + "/path/with/../../../etc/passwd:/container/path", + }, + expectError: true, // Now expecting an error due to validation + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + mounts, err := ParseMountDeclarations(tt.declarations) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, len(tt.declarations), len(mounts)) + }) + } +} + +// Additional security-focused tests + +func TestMountDeclaration_SecurityValidation(t *testing.T) { + t.Parallel() + // These tests check that our parsing is robust against various security issues + + // Test for path traversal - this should be cleaned but allowed + traversalMount := MountDeclaration("/etc/passwd:/container/path") + source, target, err := traversalMount.Parse() + require.NoError(t, err) + assert.Equal(t, "/etc/passwd", source) + assert.Equal(t, "/container/path", target) + + // Test for command injection - this should be rejected + injectionMount := MountDeclaration("$(rm -rf *):/container/path") + _, _, err = injectionMount.Parse() + require.Error(t, err) + assert.Contains(t, err.Error(), "potential command injection") + + // Test for null byte injection - this should be rejected + nullByteMount := MountDeclaration("/path/with/null\x00byte:/container/path") + _, _, err = nullByteMount.Parse() + require.Error(t, err) + assert.Contains(t, err.Error(), "null byte detected") +} + +func TestMountDeclaration_EdgeCases(t *testing.T) { + t.Parallel() + // Test with multiple colons - should fail with a clear error message + multipleColons := MountDeclaration("/path:with:multiple:colons:/container/path") + _, _, err := multipleColons.Parse() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid mount declaration format") + + // Test with very long paths + longPath := "/very/long/path/" + strings.Repeat("a", 1000) + longMount := MountDeclaration(longPath + ":/container/path") + source, target, err := longMount.Parse() + require.NoError(t, err) + assert.Equal(t, longPath, source) + assert.Equal(t, "/container/path", target) + + // Test with path containing "://" but not at the beginning + pathWithColon := MountDeclaration("/some/other/path/://:/tmp/foo") + _, _, err = pathWithColon.Parse() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid mount declaration format") +} diff --git a/registry/converters/converters_fixture_test.go b/registry/converters/converters_fixture_test.go new file mode 100644 index 0000000..849c713 --- /dev/null +++ b/registry/converters/converters_fixture_test.go @@ -0,0 +1,381 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + upstream "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + registry "github.com/stacklok/toolhive-core/registry/types" +) + +// TestConverters_Fixtures validates converter functions using JSON fixture files +// This provides a clear, maintainable way to test conversions with real-world data +func TestConverters_Fixtures(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fixtureDir string + inputFile string + expectedFile string + serverName string + convertFunc string // "ImageToServer", "ServerToImage", "RemoteToServer", "ServerToRemote" + validateFunc func(t *testing.T, input, output []byte) + }{ + { + name: "ImageMetadata to ServerJSON - GitHub", + fixtureDir: "testdata/image_to_server", + inputFile: "input_github.json", + expectedFile: "expected_github.json", + serverName: "github", + convertFunc: "ImageToServer", + validateFunc: validateImageToServerConversion, + }, + { + name: "ServerJSON to ImageMetadata - GitHub", + fixtureDir: "testdata/server_to_image", + inputFile: "input_github.json", + expectedFile: "expected_github.json", + serverName: "", + convertFunc: "ServerToImage", + validateFunc: validateServerToImageConversion, + }, + { + name: "RemoteServerMetadata to ServerJSON - Example", + fixtureDir: "testdata/remote_to_server", + inputFile: "input_example.json", + expectedFile: "expected_example.json", + serverName: "example-remote", + convertFunc: "RemoteToServer", + validateFunc: validateRemoteToServerConversion, + }, + { + name: "ServerJSON to RemoteServerMetadata - Example", + fixtureDir: "testdata/server_to_remote", + inputFile: "input_example.json", + expectedFile: "expected_example.json", + serverName: "", + convertFunc: "ServerToRemote", + validateFunc: validateServerToRemoteConversion, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Read input fixture + inputPath := filepath.Join(tt.fixtureDir, tt.inputFile) + inputData, err := os.ReadFile(inputPath) + require.NoError(t, err, "Failed to read input fixture: %s", inputPath) + + // Read expected output fixture + expectedPath := filepath.Join(tt.fixtureDir, tt.expectedFile) + expectedData, err := os.ReadFile(expectedPath) + require.NoError(t, err, "Failed to read expected fixture: %s", expectedPath) + + // Perform conversion based on type + var actualData []byte + switch tt.convertFunc { + case "ImageToServer": + actualData = convertImageToServer(t, inputData, tt.serverName) + case "ServerToImage": + actualData = convertServerToImage(t, inputData) + case "RemoteToServer": + actualData = convertRemoteToServer(t, inputData, tt.serverName) + case "ServerToRemote": + actualData = convertServerToRemote(t, inputData) + default: + t.Fatalf("Unknown conversion function: %s", tt.convertFunc) + } + + // Compare output with expected + var expected, actual interface{} + require.NoError(t, json.Unmarshal(expectedData, &expected), "Failed to parse expected JSON") + require.NoError(t, json.Unmarshal(actualData, &actual), "Failed to parse actual JSON") + + // Deep equal comparison + assert.Equal(t, expected, actual, "Conversion output doesn't match expected fixture") + + // Run additional validation if provided + if tt.validateFunc != nil { + tt.validateFunc(t, inputData, actualData) + } + }) + } +} + +// Helper functions for conversions + +func convertImageToServer(t *testing.T, inputData []byte, serverName string) []byte { + t.Helper() + var imageMetadata registry.ImageMetadata + require.NoError(t, json.Unmarshal(inputData, &imageMetadata)) + + serverJSON, err := ImageMetadataToServerJSON(serverName, &imageMetadata) + require.NoError(t, err) + + output, err := json.MarshalIndent(serverJSON, "", " ") + require.NoError(t, err) + return output +} + +func convertServerToImage(t *testing.T, inputData []byte) []byte { + t.Helper() + var serverJSON upstream.ServerJSON + require.NoError(t, json.Unmarshal(inputData, &serverJSON)) + + imageMetadata, err := ServerJSONToImageMetadata(&serverJSON) + require.NoError(t, err) + + output, err := json.MarshalIndent(imageMetadata, "", " ") + require.NoError(t, err) + return output +} + +func convertRemoteToServer(t *testing.T, inputData []byte, serverName string) []byte { + t.Helper() + var remoteMetadata registry.RemoteServerMetadata + require.NoError(t, json.Unmarshal(inputData, &remoteMetadata)) + + serverJSON, err := RemoteServerMetadataToServerJSON(serverName, &remoteMetadata) + require.NoError(t, err) + + output, err := json.MarshalIndent(serverJSON, "", " ") + require.NoError(t, err) + return output +} + +func convertServerToRemote(t *testing.T, inputData []byte) []byte { + t.Helper() + var serverJSON upstream.ServerJSON + require.NoError(t, json.Unmarshal(inputData, &serverJSON)) + + remoteMetadata, err := ServerJSONToRemoteServerMetadata(&serverJSON) + require.NoError(t, err) + + output, err := json.MarshalIndent(remoteMetadata, "", " ") + require.NoError(t, err) + return output +} + +// Validation functions - additional checks beyond JSON equality + +// getServerJSONExtensions extracts the stacklok extensions from a ServerJSON by key (image ref or URL) +func getServerJSONExtensions(t *testing.T, serverJSON *upstream.ServerJSON, key string) map[string]interface{} { + t.Helper() + + if serverJSON.Meta == nil || serverJSON.Meta.PublisherProvided == nil { + return nil + } + + stacklokData, ok := serverJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + if !ok { + return nil + } + + extensions, ok := stacklokData[key].(map[string]interface{}) + if !ok { + return nil + } + + return extensions +} + +func validateImageToServerConversion(t *testing.T, inputData, outputData []byte) { + t.Helper() + var input registry.ImageMetadata + var output upstream.ServerJSON + + require.NoError(t, json.Unmarshal(inputData, &input)) + require.NoError(t, json.Unmarshal(outputData, &output)) + + // Verify core mappings + assert.Equal(t, input.Description, output.Description, "Description should match") + assert.Equal(t, input.Title, output.Title, "Title should match") + assert.Len(t, output.Packages, 1, "Should have exactly one package") + assert.Equal(t, input.Image, output.Packages[0].Identifier, "Image identifier should match") + assert.Equal(t, input.Transport, output.Packages[0].Transport.Type, "Transport type should match") + + // Verify environment variables count + assert.Len(t, output.Packages[0].EnvironmentVariables, len(input.EnvVars), + "Environment variables count should match") + + // Verify publisher extensions exist + extensions := getServerJSONExtensions(t, &output, input.Image) + require.NotNil(t, extensions, "Extensions should exist for image") + + // Verify key extension fields + assert.Equal(t, input.Status, extensions["status"], "Status should be in extensions") + assert.Equal(t, input.Tier, extensions["tier"], "Tier should be in extensions") + assert.NotNil(t, extensions["tools"], "Tools should be in extensions") + assert.NotNil(t, extensions["tags"], "Tags should be in extensions") + + // Verify overview and tool_definitions if present + if input.Overview != "" { + assert.Equal(t, input.Overview, extensions["overview"], "Overview should be in extensions") + } + if len(input.ToolDefinitions) > 0 { + assert.NotNil(t, extensions["tool_definitions"], "tool_definitions should be in extensions") + } + + // Verify docker_tags if present + if len(input.DockerTags) > 0 { + assert.NotNil(t, extensions["docker_tags"], "docker_tags should be in extensions") + } + + // Verify proxy_port if present + if input.ProxyPort > 0 { + assert.NotNil(t, extensions["proxy_port"], "proxy_port should be in extensions") + } + + // Verify custom_metadata if present + if len(input.CustomMetadata) > 0 { + assert.NotNil(t, extensions["custom_metadata"], "custom_metadata should be in extensions") + } +} + +func validateServerToImageConversion(t *testing.T, inputData, outputData []byte) { + t.Helper() + var input upstream.ServerJSON + var output registry.ImageMetadata + + require.NoError(t, json.Unmarshal(inputData, &input)) + require.NoError(t, json.Unmarshal(outputData, &output)) + + // Verify core mappings + assert.Equal(t, input.Description, output.Description, "Description should match") + assert.Equal(t, input.Title, output.Title, "Title should match") + require.Len(t, input.Packages, 1, "Input should have exactly one package") + assert.Equal(t, input.Packages[0].Identifier, output.Image, "Image identifier should match") + assert.Equal(t, input.Packages[0].Transport.Type, output.Transport, "Transport type should match") + + // Verify environment variables were extracted + assert.Len(t, output.EnvVars, len(input.Packages[0].EnvironmentVariables), + "Environment variables count should match") + + // Verify new fields were extracted from extensions if present + extensions := getServerJSONExtensions(t, &input, output.Image) + if extensions != nil { + if _, hasDockerTags := extensions["docker_tags"]; hasDockerTags { + assert.NotNil(t, output.DockerTags, "DockerTags should be extracted from extensions") + assert.Greater(t, len(output.DockerTags), 0, "DockerTags should not be empty") + } + if _, hasProxyPort := extensions["proxy_port"]; hasProxyPort { + assert.Greater(t, output.ProxyPort, 0, "ProxyPort should be extracted from extensions") + } + if _, hasCustomMetadata := extensions["custom_metadata"]; hasCustomMetadata { + assert.NotNil(t, output.CustomMetadata, "CustomMetadata should be extracted from extensions") + assert.Greater(t, len(output.CustomMetadata), 0, "CustomMetadata should not be empty") + } + if _, hasOverview := extensions["overview"]; hasOverview { + assert.NotEmpty(t, output.Overview, "Overview should be extracted from extensions") + } + if _, hasToolDefs := extensions["tool_definitions"]; hasToolDefs { + assert.NotNil(t, output.ToolDefinitions, "ToolDefinitions should be extracted from extensions") + assert.Greater(t, len(output.ToolDefinitions), 0, "ToolDefinitions should not be empty") + } + } +} + +func validateRemoteToServerConversion(t *testing.T, inputData, outputData []byte) { + t.Helper() + var input registry.RemoteServerMetadata + var output upstream.ServerJSON + + require.NoError(t, json.Unmarshal(inputData, &input)) + require.NoError(t, json.Unmarshal(outputData, &output)) + + // Verify core mappings + assert.Equal(t, input.Description, output.Description, "Description should match") + assert.Equal(t, input.Title, output.Title, "Title should match") + require.Len(t, output.Remotes, 1, "Should have exactly one remote") + assert.Equal(t, input.URL, output.Remotes[0].URL, "Remote URL should match") + assert.Equal(t, input.Transport, output.Remotes[0].Type, "Transport type should match") + + // Verify headers count + assert.Len(t, output.Remotes[0].Headers, len(input.Headers), + "Headers count should match") + + // Get extensions once and verify all fields + extensions := getServerJSONExtensions(t, &output, input.URL) + + // Verify overview and tool_definitions if present + if input.Overview != "" { + require.NotNil(t, extensions, "Extensions should exist when overview is present") + assert.Equal(t, input.Overview, extensions["overview"], "Overview should be in extensions") + } + if len(input.ToolDefinitions) > 0 { + require.NotNil(t, extensions, "Extensions should exist when tool_definitions are present") + assert.NotNil(t, extensions["tool_definitions"], "tool_definitions should be in extensions") + } + + // Verify env_vars if input has them + if len(input.EnvVars) > 0 { + require.NotNil(t, extensions, "Extensions should exist when env_vars are present") + assert.NotNil(t, extensions["env_vars"], "env_vars should be in extensions") + } + + // Verify oauth_config if present + if input.OAuthConfig != nil { + require.NotNil(t, extensions, "Extensions should exist when oauth_config is present") + assert.NotNil(t, extensions["oauth_config"], "oauth_config should be in extensions") + } + + // Verify custom_metadata if present + if len(input.CustomMetadata) > 0 { + require.NotNil(t, extensions, "Extensions should exist when custom_metadata is present") + assert.NotNil(t, extensions["custom_metadata"], "custom_metadata should be in extensions") + } +} + +func validateServerToRemoteConversion(t *testing.T, inputData, outputData []byte) { + t.Helper() + var input upstream.ServerJSON + var output registry.RemoteServerMetadata + + require.NoError(t, json.Unmarshal(inputData, &input)) + require.NoError(t, json.Unmarshal(outputData, &output)) + + // Verify core mappings + assert.Equal(t, input.Description, output.Description, "Description should match") + assert.Equal(t, input.Title, output.Title, "Title should match") + require.Len(t, input.Remotes, 1, "Input should have exactly one remote") + assert.Equal(t, input.Remotes[0].URL, output.URL, "Remote URL should match") + assert.Equal(t, input.Remotes[0].Type, output.Transport, "Transport type should match") + + // Verify headers were extracted + assert.Len(t, output.Headers, len(input.Remotes[0].Headers), + "Headers count should match") + + // Verify fields were extracted from extensions if present + extensions := getServerJSONExtensions(t, &input, output.URL) + if extensions != nil { + if _, hasOverview := extensions["overview"]; hasOverview { + assert.NotEmpty(t, output.Overview, "Overview should be extracted from extensions") + } + if _, hasToolDefs := extensions["tool_definitions"]; hasToolDefs { + assert.NotNil(t, output.ToolDefinitions, "ToolDefinitions should be extracted from extensions") + assert.Greater(t, len(output.ToolDefinitions), 0, "ToolDefinitions should not be empty") + } + if _, hasEnvVars := extensions["env_vars"]; hasEnvVars { + assert.NotNil(t, output.EnvVars, "EnvVars should be extracted from extensions") + assert.Greater(t, len(output.EnvVars), 0, "EnvVars should not be empty") + } + if _, hasOAuth := extensions["oauth_config"]; hasOAuth { + assert.NotNil(t, output.OAuthConfig, "OAuthConfig should be extracted from extensions") + } + if _, hasCustomMetadata := extensions["custom_metadata"]; hasCustomMetadata { + assert.NotNil(t, output.CustomMetadata, "CustomMetadata should be extracted from extensions") + assert.Greater(t, len(output.CustomMetadata), 0, "CustomMetadata should not be empty") + } + } +} diff --git a/registry/converters/converters_test.go b/registry/converters/converters_test.go new file mode 100644 index 0000000..8840c99 --- /dev/null +++ b/registry/converters/converters_test.go @@ -0,0 +1,1334 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "encoding/json" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + upstream "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/modelcontextprotocol/registry/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + registry "github.com/stacklok/toolhive-core/registry/types" +) + +// Test Helpers + +// createTestServerJSON creates a valid ServerJSON for testing with OCI package +func createTestServerJSON() *upstream.ServerJSON { + return &upstream.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "io.github.stacklok/test-server", + Title: "Test Server", + Description: "Test MCP server", + Version: "1.0.0", + Repository: &model.Repository{ + URL: "https://github.com/test/repo", + Source: "github", + }, + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeOCI, + Identifier: "ghcr.io/test/server:latest", + Transport: model.Transport{ + Type: model.TransportTypeStdio, + }, + }, + }, + Meta: &upstream.ServerMeta{ + PublisherProvided: map[string]interface{}{ + "io.github.stacklok": map[string]interface{}{ + "ghcr.io/test/server:latest": map[string]interface{}{ + "status": "active", + "tier": "Official", + "tools": []interface{}{"tool1", "tool2"}, + "tags": []interface{}{"test", "example"}, + "overview": "# Test Server\n\nA test MCP server.", + "tool_definitions": []interface{}{ + map[string]interface{}{ + "name": "tool1", + "description": "First tool", + }, + }, + "metadata": map[string]interface{}{ + "stars": float64(100), + "last_updated": "2025-01-01", + }, + }, + }, + }, + }, + } +} + +// createTestImageMetadata creates a valid ImageMetadata for testing +func createTestImageMetadata() *registry.ImageMetadata { + return ®istry.ImageMetadata{ + BaseServerMetadata: registry.BaseServerMetadata{ + Title: "Test Server", + Description: "Test MCP server", + Transport: model.TransportTypeStdio, + RepositoryURL: "https://github.com/test/repo", + Status: "active", + Tier: "Official", + Tools: []string{"tool1", "tool2"}, + Tags: []string{"test", "example"}, + Overview: "# Test Server\n\nA test MCP server.", + ToolDefinitions: []mcp.Tool{ + {Name: "tool1", Description: "First tool"}, + }, + Metadata: ®istry.Metadata{ + Stars: 100, + LastUpdated: "2025-01-01", + }, + }, + Image: "ghcr.io/test/server:latest", + } +} + +// createTestRemoteServerMetadata creates a valid RemoteServerMetadata for testing +func createTestRemoteServerMetadata() *registry.RemoteServerMetadata { + return ®istry.RemoteServerMetadata{ + BaseServerMetadata: registry.BaseServerMetadata{ + Title: "Test Remote", + Description: "Test remote server", + Transport: "sse", + RepositoryURL: "https://github.com/test/remote", + Status: "active", + Tier: "Official", + Tools: []string{"tool1"}, + Tags: []string{"remote"}, + Overview: "# Test Remote\n\nA test remote server.", + ToolDefinitions: []mcp.Tool{ + {Name: "tool1", Description: "Remote tool"}, + }, + }, + URL: "https://api.example.com/mcp", + } +} + +// Test Suite 1: ServerJSONToImageMetadata + +func TestServerJSONToImageMetadata_Success(t *testing.T) { + t.Parallel() + + serverJSON := createTestServerJSON() + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, imageMetadata) + + assert.Equal(t, "ghcr.io/test/server:latest", imageMetadata.Image) + assert.Equal(t, "Test Server", imageMetadata.Title) + assert.Equal(t, "Test MCP server", imageMetadata.Description) + assert.Equal(t, model.TransportTypeStdio, imageMetadata.Transport) + assert.Equal(t, "https://github.com/test/repo", imageMetadata.RepositoryURL) + assert.Equal(t, "active", imageMetadata.Status) + assert.Equal(t, "Official", imageMetadata.Tier) + assert.Equal(t, []string{"tool1", "tool2"}, imageMetadata.Tools) + assert.Equal(t, []string{"test", "example"}, imageMetadata.Tags) + assert.Equal(t, "# Test Server\n\nA test MCP server.", imageMetadata.Overview) + require.Len(t, imageMetadata.ToolDefinitions, 1) + assert.Equal(t, "tool1", imageMetadata.ToolDefinitions[0].Name) + assert.NotNil(t, imageMetadata.Metadata) + assert.Equal(t, 100, imageMetadata.Metadata.Stars) + assert.Equal(t, "2025-01-01", imageMetadata.Metadata.LastUpdated) +} + +func TestServerJSONToImageMetadata_NilInput(t *testing.T) { + t.Parallel() + + imageMetadata, err := ServerJSONToImageMetadata(nil) + + assert.Error(t, err) + assert.Nil(t, imageMetadata) + assert.Contains(t, err.Error(), "serverJSON cannot be nil") +} + +func TestServerJSONToImageMetadata_NoPackages(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "test", + Packages: []model.Package{}, + } + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + assert.Error(t, err) + assert.Nil(t, imageMetadata) + assert.Contains(t, err.Error(), "has no packages") +} + +func TestServerJSONToImageMetadata_NoOCIPackages(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "test", + Packages: []model.Package{ + { + RegistryType: "npm", + Identifier: "test-package", + }, + }, + } + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + assert.Error(t, err) + assert.Nil(t, imageMetadata) + assert.Contains(t, err.Error(), "has no OCI packages") +} + +func TestServerJSONToImageMetadata_MultipleOCIPackages(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "test", + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeOCI, + Identifier: "image1:latest", + }, + { + RegistryType: model.RegistryTypeOCI, + Identifier: "image2:latest", + }, + }, + } + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + assert.Error(t, err) + assert.Nil(t, imageMetadata) + assert.Contains(t, err.Error(), "has 2 OCI packages") +} + +func TestServerJSONToImageMetadata_WithEnvVars(t *testing.T) { + t.Parallel() + + serverJSON := createTestServerJSON() + serverJSON.Packages[0].EnvironmentVariables = []model.KeyValueInput{ + { + Name: "API_KEY", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "API Key", + IsRequired: true, + IsSecret: true, + Default: "default-key", + }, + }, + }, + { + Name: "DEBUG", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "Debug mode", + IsRequired: false, + IsSecret: false, + Default: "false", + }, + }, + }, + } + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, imageMetadata) + require.Len(t, imageMetadata.EnvVars, 2) + + assert.Equal(t, "API_KEY", imageMetadata.EnvVars[0].Name) + assert.Equal(t, "API Key", imageMetadata.EnvVars[0].Description) + assert.True(t, imageMetadata.EnvVars[0].Required) + assert.True(t, imageMetadata.EnvVars[0].Secret) + assert.Equal(t, "default-key", imageMetadata.EnvVars[0].Default) + + assert.Equal(t, "DEBUG", imageMetadata.EnvVars[1].Name) + assert.Equal(t, "Debug mode", imageMetadata.EnvVars[1].Description) + assert.False(t, imageMetadata.EnvVars[1].Required) + assert.False(t, imageMetadata.EnvVars[1].Secret) + assert.Equal(t, "false", imageMetadata.EnvVars[1].Default) +} + +func TestServerJSONToImageMetadata_WithTargetPort(t *testing.T) { + t.Parallel() + + serverJSON := createTestServerJSON() + serverJSON.Packages[0].Transport = model.Transport{ + Type: model.TransportTypeStreamableHTTP, + URL: "http://localhost:9090", + } + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, imageMetadata) + assert.Equal(t, 9090, imageMetadata.TargetPort) +} + +func TestServerJSONToImageMetadata_InvalidPortURL(t *testing.T) { + t.Parallel() + + serverJSON := createTestServerJSON() + serverJSON.Packages[0].Transport = model.Transport{ + Type: model.TransportTypeStreamableHTTP, + URL: "not-a-valid-url", + } + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, imageMetadata) + assert.Equal(t, 0, imageMetadata.TargetPort) // Should default to 0 on parse failure +} + +func TestServerJSONToImageMetadata_MissingPublisherExtensions(t *testing.T) { + t.Parallel() + + serverJSON := createTestServerJSON() + serverJSON.Meta = nil + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, imageMetadata) + assert.Equal(t, "", imageMetadata.Status) + assert.Equal(t, "", imageMetadata.Tier) + assert.Nil(t, imageMetadata.Tools) + assert.Nil(t, imageMetadata.Tags) + assert.Nil(t, imageMetadata.Metadata) +} + +// Test Suite 2: ImageMetadataToServerJSON + +func TestImageMetadataToServerJSON_Success(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + serverJSON, err := ImageMetadataToServerJSON("test-server", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + + assert.Equal(t, model.CurrentSchemaURL, serverJSON.Schema) + assert.Equal(t, "io.github.stacklok/test-server", serverJSON.Name) + assert.Equal(t, "Test Server", serverJSON.Title) + assert.Equal(t, "Test MCP server", serverJSON.Description) + assert.Equal(t, "1.0.0", serverJSON.Version) + assert.Equal(t, "https://github.com/test/repo", serverJSON.Repository.URL) + assert.Len(t, serverJSON.Packages, 1) + assert.Equal(t, model.RegistryTypeOCI, serverJSON.Packages[0].RegistryType) + assert.Equal(t, "ghcr.io/test/server:latest", serverJSON.Packages[0].Identifier) + assert.NotNil(t, serverJSON.Meta) + assert.NotNil(t, serverJSON.Meta.PublisherProvided) +} + +func TestImageMetadataToServerJSON_NilInput(t *testing.T) { + t.Parallel() + + serverJSON, err := ImageMetadataToServerJSON("test", nil) + + assert.Error(t, err) + assert.Nil(t, serverJSON) + assert.Contains(t, err.Error(), "imageMetadata cannot be nil") +} + +func TestImageMetadataToServerJSON_EmptyName(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + serverJSON, err := ImageMetadataToServerJSON("", imageMetadata) + + assert.Error(t, err) + assert.Nil(t, serverJSON) + assert.Contains(t, err.Error(), "name cannot be empty") +} + +func TestImageMetadataToServerJSON_WithEnvVars(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + imageMetadata.EnvVars = []*registry.EnvVar{ + { + Name: "API_KEY", + Description: "API Key", + Required: true, + Secret: true, + Default: "default", + }, + } + + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.Len(t, serverJSON.Packages, 1) + require.Len(t, serverJSON.Packages[0].EnvironmentVariables, 1) + + envVar := serverJSON.Packages[0].EnvironmentVariables[0] + assert.Equal(t, "API_KEY", envVar.Name) + assert.Equal(t, "API Key", envVar.Description) + assert.True(t, envVar.IsRequired) + assert.True(t, envVar.IsSecret) + assert.Equal(t, "default", envVar.Default) +} + +func TestImageMetadataToServerJSON_WithTargetPort(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + imageMetadata.Transport = model.TransportTypeStreamableHTTP + imageMetadata.TargetPort = 9090 + + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.Len(t, serverJSON.Packages, 1) + + assert.Equal(t, model.TransportTypeStreamableHTTP, serverJSON.Packages[0].Transport.Type) + assert.Equal(t, "http://localhost:9090", serverJSON.Packages[0].Transport.URL) +} + +func TestImageMetadataToServerJSON_HTTPTransportNoPort(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + imageMetadata.Transport = model.TransportTypeStreamableHTTP + imageMetadata.TargetPort = 0 // No port specified + + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.Len(t, serverJSON.Packages, 1) + + assert.Equal(t, model.TransportTypeStreamableHTTP, serverJSON.Packages[0].Transport.Type) + assert.Equal(t, "http://localhost", serverJSON.Packages[0].Transport.URL) // No port in URL +} + +func TestImageMetadataToServerJSON_StdioTransport(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + imageMetadata.Transport = model.TransportTypeStdio + + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.Len(t, serverJSON.Packages, 1) + + assert.Equal(t, model.TransportTypeStdio, serverJSON.Packages[0].Transport.Type) + assert.Empty(t, serverJSON.Packages[0].Transport.URL) +} + +func TestImageMetadataToServerJSON_EmptyTransportDefaultsToStdio(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + imageMetadata.Transport = "" + + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.Len(t, serverJSON.Packages, 1) + + assert.Equal(t, model.TransportTypeStdio, serverJSON.Packages[0].Transport.Type) +} + +func TestImageMetadataToServerJSON_WithPublisherExtensions(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.NotNil(t, serverJSON.Meta) + require.NotNil(t, serverJSON.Meta.PublisherProvided) + + stacklokData, ok := serverJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + require.True(t, ok) + + imageData, ok := stacklokData["ghcr.io/test/server:latest"].(map[string]interface{}) + require.True(t, ok) + + assert.Equal(t, "active", imageData["status"]) + assert.Equal(t, "Official", imageData["tier"]) +} + +func TestImageMetadataToServerJSON_EmptyStatusDefaultsToActive(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + imageMetadata.Status = "" // Empty status should default to "active" + + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + + stacklokData, ok := serverJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + require.True(t, ok) + + imageData, ok := stacklokData["ghcr.io/test/server:latest"].(map[string]interface{}) + require.True(t, ok) + + assert.Equal(t, "active", imageData["status"]) +} + +func TestRemoteServerMetadataToServerJSON_EmptyStatusDefaultsToActive(t *testing.T) { + t.Parallel() + + remoteMetadata := createTestRemoteServerMetadata() + remoteMetadata.Status = "" // Empty status should default to "active" + + serverJSON, err := RemoteServerMetadataToServerJSON("test-remote", remoteMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + + stacklokData, ok := serverJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + require.True(t, ok) + + remoteData, ok := stacklokData["https://api.example.com/mcp"].(map[string]interface{}) + require.True(t, ok) + + assert.Equal(t, "active", remoteData["status"]) +} + +func TestImageMetadataToServerJSON_ReverseDNSName(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + serverJSON, err := ImageMetadataToServerJSON("fetch", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + assert.Equal(t, "io.github.stacklok/fetch", serverJSON.Name) +} + +// Test Suite 3: ServerJSONToRemoteServerMetadata + +func TestServerJSONToRemoteServerMetadata_Success(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "io.github.stacklok/test-remote", + Title: "Test Remote", + Description: "Test remote server", + Repository: &model.Repository{ + URL: "https://github.com/test/remote", + }, + Remotes: []model.Transport{ + { + Type: "sse", + URL: "https://api.example.com/mcp", + }, + }, + Meta: &upstream.ServerMeta{ + PublisherProvided: map[string]interface{}{ + "io.github.stacklok": map[string]interface{}{ + "https://api.example.com/mcp": map[string]interface{}{ + "status": "active", + "tier": "Official", + "tools": []interface{}{"tool1"}, + "overview": "# Test Remote\n\nA test remote server.", + "tool_definitions": []interface{}{ + map[string]interface{}{ + "name": "tool1", + "description": "Remote tool", + }, + }, + }, + }, + }, + }, + } + + remoteMetadata, err := ServerJSONToRemoteServerMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, remoteMetadata) + + assert.Equal(t, "https://api.example.com/mcp", remoteMetadata.URL) + assert.Equal(t, "Test Remote", remoteMetadata.Title) + assert.Equal(t, "Test remote server", remoteMetadata.Description) + assert.Equal(t, "sse", remoteMetadata.Transport) + assert.Equal(t, "https://github.com/test/remote", remoteMetadata.RepositoryURL) + assert.Equal(t, "active", remoteMetadata.Status) + assert.Equal(t, "Official", remoteMetadata.Tier) + assert.Equal(t, []string{"tool1"}, remoteMetadata.Tools) + assert.Equal(t, "# Test Remote\n\nA test remote server.", remoteMetadata.Overview) + require.Len(t, remoteMetadata.ToolDefinitions, 1) + assert.Equal(t, "tool1", remoteMetadata.ToolDefinitions[0].Name) +} + +func TestServerJSONToRemoteServerMetadata_NilInput(t *testing.T) { + t.Parallel() + + remoteMetadata, err := ServerJSONToRemoteServerMetadata(nil) + + assert.Error(t, err) + assert.Nil(t, remoteMetadata) + assert.Contains(t, err.Error(), "serverJSON cannot be nil") +} + +func TestServerJSONToRemoteServerMetadata_NoRemotes(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "test", + Remotes: []model.Transport{}, + } + + remoteMetadata, err := ServerJSONToRemoteServerMetadata(serverJSON) + + assert.Error(t, err) + assert.Nil(t, remoteMetadata) + assert.Contains(t, err.Error(), "has no remotes") +} + +func TestServerJSONToRemoteServerMetadata_WithHeaders(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "test", + Description: "Test", + Remotes: []model.Transport{ + { + Type: "sse", + URL: "https://api.example.com", + Headers: []model.KeyValueInput{ + { + Name: "Authorization", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "Auth token", + IsRequired: true, + IsSecret: true, + }, + }, + }, + }, + }, + }, + } + + remoteMetadata, err := ServerJSONToRemoteServerMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, remoteMetadata) + require.Len(t, remoteMetadata.Headers, 1) + + assert.Equal(t, "Authorization", remoteMetadata.Headers[0].Name) + assert.Equal(t, "Auth token", remoteMetadata.Headers[0].Description) + assert.True(t, remoteMetadata.Headers[0].Required) + assert.True(t, remoteMetadata.Headers[0].Secret) +} + +func TestServerJSONToRemoteServerMetadata_MissingPublisherExtensions(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "test", + Description: "Test", + Remotes: []model.Transport{ + { + Type: "sse", + URL: "https://api.example.com", + }, + }, + Meta: nil, + } + + remoteMetadata, err := ServerJSONToRemoteServerMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, remoteMetadata) + assert.Equal(t, "", remoteMetadata.Status) + assert.Equal(t, "", remoteMetadata.Tier) +} + +// Test Suite 4: RemoteServerMetadataToServerJSON + +func TestRemoteServerMetadataToServerJSON_Success(t *testing.T) { + t.Parallel() + + remoteMetadata := createTestRemoteServerMetadata() + serverJSON, err := RemoteServerMetadataToServerJSON("test-remote", remoteMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + + assert.Equal(t, model.CurrentSchemaURL, serverJSON.Schema) + assert.Equal(t, "io.github.stacklok/test-remote", serverJSON.Name) + assert.Equal(t, "Test Remote", serverJSON.Title) + assert.Equal(t, "Test remote server", serverJSON.Description) + assert.Equal(t, "https://github.com/test/remote", serverJSON.Repository.URL) + assert.Len(t, serverJSON.Remotes, 1) + assert.Equal(t, "sse", serverJSON.Remotes[0].Type) + assert.Equal(t, "https://api.example.com/mcp", serverJSON.Remotes[0].URL) +} + +func TestRemoteServerMetadataToServerJSON_NilInput(t *testing.T) { + t.Parallel() + + serverJSON, err := RemoteServerMetadataToServerJSON("test", nil) + + assert.Error(t, err) + assert.Nil(t, serverJSON) + assert.Contains(t, err.Error(), "remoteMetadata cannot be nil") +} + +func TestRemoteServerMetadataToServerJSON_EmptyName(t *testing.T) { + t.Parallel() + + remoteMetadata := createTestRemoteServerMetadata() + serverJSON, err := RemoteServerMetadataToServerJSON("", remoteMetadata) + + assert.Error(t, err) + assert.Nil(t, serverJSON) + assert.Contains(t, err.Error(), "name cannot be empty") +} + +func TestRemoteServerMetadataToServerJSON_WithHeaders(t *testing.T) { + t.Parallel() + + remoteMetadata := createTestRemoteServerMetadata() + remoteMetadata.Headers = []*registry.Header{ + { + Name: "Authorization", + Description: "Auth header", + Required: true, + Secret: true, + }, + } + + serverJSON, err := RemoteServerMetadataToServerJSON("test", remoteMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.Len(t, serverJSON.Remotes, 1) + require.Len(t, serverJSON.Remotes[0].Headers, 1) + + header := serverJSON.Remotes[0].Headers[0] + assert.Equal(t, "Authorization", header.Name) + assert.Equal(t, "Auth header", header.Description) + assert.True(t, header.IsRequired) + assert.True(t, header.IsSecret) +} + +func TestRemoteServerMetadataToServerJSON_WithPublisherExtensions(t *testing.T) { + t.Parallel() + + remoteMetadata := createTestRemoteServerMetadata() + serverJSON, err := RemoteServerMetadataToServerJSON("test", remoteMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.NotNil(t, serverJSON.Meta) + require.NotNil(t, serverJSON.Meta.PublisherProvided) + + stacklokData, ok := serverJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + require.True(t, ok) + + remoteData, ok := stacklokData["https://api.example.com/mcp"].(map[string]interface{}) + require.True(t, ok) + + assert.Equal(t, "active", remoteData["status"]) + assert.Equal(t, "Official", remoteData["tier"]) +} + +// Test Suite 5: Utility Functions + +func TestExtractServerName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "reverse DNS format", + input: "io.github.stacklok/fetch", + expected: "fetch", + }, + { + name: "no slash", + input: "fetch", + expected: "fetch", + }, + { + name: "returns original if multiple slashes", + input: "io.github.stacklok/mcp/server", + expected: "io.github.stacklok/mcp/server", // Function only splits on first slash, returns original if not exactly 2 parts + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := ExtractServerName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildReverseDNSName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple name", + input: "fetch", + expected: "io.github.stacklok/fetch", + }, + { + name: "already formatted", + input: "io.github.stacklok/fetch", + expected: "io.github.stacklok/fetch", + }, + { + name: "other namespace", + input: "com.example/server", + expected: "com.example/server", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := BuildReverseDNSName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Test Suite 6: Round-trip Conversion Tests + +func TestRoundTrip_ImageMetadata(t *testing.T) { + t.Parallel() + + // Start with ImageMetadata + original := createTestImageMetadata() + + // Convert to ServerJSON + serverJSON, err := ImageMetadataToServerJSON("test-server", original) + require.NoError(t, err) + + // Convert back to ImageMetadata + result, err := ServerJSONToImageMetadata(serverJSON) + require.NoError(t, err) + + // Verify data preserved + assert.Equal(t, original.Image, result.Image) + assert.Equal(t, original.Title, result.Title) + assert.Equal(t, original.Description, result.Description) + assert.Equal(t, original.Transport, result.Transport) + assert.Equal(t, original.RepositoryURL, result.RepositoryURL) + assert.Equal(t, original.Status, result.Status) + assert.Equal(t, original.Tier, result.Tier) + assert.Equal(t, original.Tools, result.Tools) + assert.Equal(t, original.Tags, result.Tags) + assert.Equal(t, original.Overview, result.Overview) + assert.Len(t, result.ToolDefinitions, len(original.ToolDefinitions)) + + if original.Metadata != nil { + require.NotNil(t, result.Metadata) + assert.Equal(t, original.Metadata.Stars, result.Metadata.Stars) + assert.Equal(t, original.Metadata.LastUpdated, result.Metadata.LastUpdated) + } +} + +func TestRoundTrip_RemoteServerMetadata(t *testing.T) { + t.Parallel() + + // Start with RemoteServerMetadata + original := createTestRemoteServerMetadata() + + // Convert to ServerJSON + serverJSON, err := RemoteServerMetadataToServerJSON("test-remote", original) + require.NoError(t, err) + + // Convert back to RemoteServerMetadata + result, err := ServerJSONToRemoteServerMetadata(serverJSON) + require.NoError(t, err) + + // Verify data preserved + assert.Equal(t, original.URL, result.URL) + assert.Equal(t, original.Title, result.Title) + assert.Equal(t, original.Description, result.Description) + assert.Equal(t, original.Transport, result.Transport) + assert.Equal(t, original.RepositoryURL, result.RepositoryURL) + assert.Equal(t, original.Status, result.Status) + assert.Equal(t, original.Tier, result.Tier) + assert.Equal(t, original.Tools, result.Tools) + assert.Equal(t, original.Tags, result.Tags) + assert.Equal(t, original.Overview, result.Overview) + assert.Len(t, result.ToolDefinitions, len(original.ToolDefinitions)) +} + +func TestRoundTrip_ImageMetadataWithAllFields(t *testing.T) { + t.Parallel() + + // Create ImageMetadata with maximum field population + original := ®istry.ImageMetadata{ + BaseServerMetadata: registry.BaseServerMetadata{ + Description: "Full featured server", + Transport: model.TransportTypeStreamableHTTP, + RepositoryURL: "https://github.com/test/full", + Status: "active", + Tier: "Official", + Tools: []string{"tool1", "tool2", "tool3"}, + Tags: []string{"tag1", "tag2"}, + Metadata: ®istry.Metadata{ + Stars: 500, + LastUpdated: "2025-10-23", + }, + }, + Image: "ghcr.io/test/full:v1.0.0", + TargetPort: 8080, + EnvVars: []*registry.EnvVar{ + { + Name: "API_KEY", + Description: "API Key for authentication", + Required: true, + Secret: true, + Default: "", + }, + { + Name: "LOG_LEVEL", + Description: "Logging level", + Required: false, + Secret: false, + Default: "info", + }, + }, + } + + // Round trip + serverJSON, err := ImageMetadataToServerJSON("full-server", original) + require.NoError(t, err) + + result, err := ServerJSONToImageMetadata(serverJSON) + require.NoError(t, err) + + // Verify all fields preserved + assert.Equal(t, original.Image, result.Image) + assert.Equal(t, original.Description, result.Description) + assert.Equal(t, original.Transport, result.Transport) + assert.Equal(t, original.RepositoryURL, result.RepositoryURL) + assert.Equal(t, original.Status, result.Status) + assert.Equal(t, original.Tier, result.Tier) + assert.Equal(t, original.Tools, result.Tools) + assert.Equal(t, original.Tags, result.Tags) + assert.Equal(t, original.TargetPort, result.TargetPort) + + require.Len(t, result.EnvVars, len(original.EnvVars)) + for i := range original.EnvVars { + assert.Equal(t, original.EnvVars[i].Name, result.EnvVars[i].Name) + assert.Equal(t, original.EnvVars[i].Description, result.EnvVars[i].Description) + assert.Equal(t, original.EnvVars[i].Required, result.EnvVars[i].Required) + assert.Equal(t, original.EnvVars[i].Secret, result.EnvVars[i].Secret) + assert.Equal(t, original.EnvVars[i].Default, result.EnvVars[i].Default) + } + + require.NotNil(t, result.Metadata) + assert.Equal(t, original.Metadata.Stars, result.Metadata.Stars) + assert.Equal(t, original.Metadata.LastUpdated, result.Metadata.LastUpdated) +} + +// TestRealWorld_GitHubServer tests conversion using the actual GitHub MCP server data as a template +// This test verifies that our converters can handle real-world production data correctly +func TestRealWorld_GitHubServer(t *testing.T) { + t.Parallel() + + // Create the official ServerJSON format (from the actual registry) + officialFormat := &upstream.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "io.github.github/github-mcp-server", + Description: "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language.", + Version: "0.19.1", + Repository: &model.Repository{ + URL: "https://github.com/github/github-mcp-server", + Source: "github", + }, + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeOCI, + Identifier: "ghcr.io/github/github-mcp-server:0.19.1", + Transport: model.Transport{ + Type: model.TransportTypeStdio, + }, + EnvironmentVariables: []model.KeyValueInput{ + { + Name: "GITHUB_PERSONAL_ACCESS_TOKEN", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "Your GitHub personal access token with appropriate scopes.", + IsRequired: true, + IsSecret: true, + }, + }, + }, + }, + }, + }, + Meta: &upstream.ServerMeta{ + PublisherProvided: map[string]interface{}{ + "io.github.stacklok": map[string]interface{}{ + "ghcr.io/github/github-mcp-server:0.19.1": map[string]interface{}{ + "status": "active", + "tier": "Official", + "tools": []interface{}{ + "add_comment_to_pending_review", "add_issue_comment", "add_sub_issue", + "assign_copilot_to_issue", "create_branch", "create_issue", + "create_or_update_file", "create_pull_request", "create_repository", + "delete_file", "fork_repository", "get_commit", "get_file_contents", + "get_issue", "get_issue_comments", "get_label", "get_latest_release", + "get_me", "get_release_by_tag", "get_tag", "get_team_members", + "get_teams", "list_branches", "list_commits", "list_issue_types", + "list_issues", "list_label", "list_pull_requests", "list_releases", + "list_sub_issues", "list_tags", "merge_pull_request", + "pull_request_read", "pull_request_review_write", "push_files", + "remove_sub_issue", "reprioritize_sub_issue", "request_copilot_review", + "search_code", "search_issues", "search_pull_requests", + "search_repositories", "search_users", "update_issue", + "update_pull_request", "update_pull_request_branch", + }, + "tags": []interface{}{ + "api", "create", "fork", "github", "list", + "pull-request", "push", "repository", "search", "update", "issues", + }, + "metadata": map[string]interface{}{ + "stars": float64(23700), + "last_updated": "2025-10-18T02:26:51Z", + }, + }, + }, + }, + }, + } + + // Convert official format to ImageMetadata + imageMetadata, err := ServerJSONToImageMetadata(officialFormat) + require.NoError(t, err, "Should convert official format to ImageMetadata") + require.NotNil(t, imageMetadata) + + // Verify core fields + assert.Equal(t, "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language.", imageMetadata.Description) + assert.Equal(t, "stdio", imageMetadata.Transport) + assert.Equal(t, "ghcr.io/github/github-mcp-server:0.19.1", imageMetadata.Image) + assert.Equal(t, "https://github.com/github/github-mcp-server", imageMetadata.RepositoryURL) + + // Verify environment variables + require.Len(t, imageMetadata.EnvVars, 1) + assert.Equal(t, "GITHUB_PERSONAL_ACCESS_TOKEN", imageMetadata.EnvVars[0].Name) + assert.Equal(t, "Your GitHub personal access token with appropriate scopes.", imageMetadata.EnvVars[0].Description) + assert.True(t, imageMetadata.EnvVars[0].Required) + assert.True(t, imageMetadata.EnvVars[0].Secret) + + // Verify publisher extensions were extracted + assert.Equal(t, "active", imageMetadata.Status) + assert.Equal(t, "Official", imageMetadata.Tier) + require.Len(t, imageMetadata.Tools, 46, "Should have 46 tools") + assert.Contains(t, imageMetadata.Tools, "create_pull_request") + assert.Contains(t, imageMetadata.Tools, "search_repositories") + require.Len(t, imageMetadata.Tags, 11, "Should have 11 tags") + assert.Contains(t, imageMetadata.Tags, "github") + assert.Contains(t, imageMetadata.Tags, "pull-request") + + // Verify metadata + require.NotNil(t, imageMetadata.Metadata) + assert.Equal(t, 23700, imageMetadata.Metadata.Stars) + assert.Equal(t, "2025-10-18T02:26:51Z", imageMetadata.Metadata.LastUpdated) + + // Test round-trip: Convert back to ServerJSON + resultServerJSON, err := ImageMetadataToServerJSON("github-mcp-server", imageMetadata) + require.NoError(t, err, "Should convert ImageMetadata back to ServerJSON") + require.NotNil(t, resultServerJSON) + + // Verify round-trip preserved core data (including original canonical name) + assert.Equal(t, "io.github.github/github-mcp-server", resultServerJSON.Name) + assert.Equal(t, officialFormat.Description, resultServerJSON.Description) + assert.Equal(t, officialFormat.Repository.URL, resultServerJSON.Repository.URL) + + // Verify packages + require.Len(t, resultServerJSON.Packages, 1) + assert.Equal(t, model.RegistryTypeOCI, resultServerJSON.Packages[0].RegistryType) + assert.Equal(t, "ghcr.io/github/github-mcp-server:0.19.1", resultServerJSON.Packages[0].Identifier) + assert.Equal(t, model.TransportTypeStdio, resultServerJSON.Packages[0].Transport.Type) + + // Verify publisher extensions are present in round-trip + require.NotNil(t, resultServerJSON.Meta) + require.NotNil(t, resultServerJSON.Meta.PublisherProvided) + stacklokData, ok := resultServerJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + require.True(t, ok, "Should have io.github.stacklok namespace") + imageData, ok := stacklokData["ghcr.io/github/github-mcp-server:0.19.1"].(map[string]interface{}) + require.True(t, ok, "Should have image-specific extensions") + + // Verify extensions preserved + assert.Equal(t, "active", imageData["status"]) + assert.Equal(t, "Official", imageData["tier"]) + + // Verify tools are preserved as interface slice + tools, ok := imageData["tools"].([]interface{}) + require.True(t, ok, "Tools should be []interface{}") + assert.Len(t, tools, 46) + + // Verify tags are preserved + tags, ok := imageData["tags"].([]interface{}) + require.True(t, ok, "Tags should be []interface{}") + assert.Len(t, tags, 11) + + // Verify metadata is preserved + metadata, ok := imageData["metadata"].(map[string]interface{}) + require.True(t, ok, "Metadata should be present") + assert.Equal(t, float64(23700), metadata["stars"]) + assert.Equal(t, "2025-10-18T02:26:51Z", metadata["last_updated"]) +} + +// TestRealWorld_GitHubServer_ExactData tests conversion using EXACT data from the user +// This uses the actual JSON strings provided to verify visual correctness +func TestRealWorld_GitHubServer_ExactData(t *testing.T) { + t.Parallel() + + // EXACT ImageMetadata format as provided by user (from build/registry.json) + imageMetadataJSON := `{ + "description": "Provides integration with GitHub's APIs", + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": [ + "add_comment_to_pending_review", + "add_issue_comment", + "add_sub_issue", + "assign_copilot_to_issue", + "create_branch", + "create_issue", + "create_or_update_file", + "create_pull_request", + "create_repository", + "delete_file", + "fork_repository", + "get_commit", + "get_file_contents", + "get_issue", + "get_issue_comments", + "get_label", + "get_latest_release", + "get_me", + "get_release_by_tag", + "get_tag", + "get_team_members", + "get_teams", + "list_branches", + "list_commits", + "list_issue_types", + "list_issues", + "list_label", + "list_pull_requests", + "list_releases", + "list_sub_issues", + "list_tags", + "merge_pull_request", + "pull_request_read", + "pull_request_review_write", + "push_files", + "remove_sub_issue", + "reprioritize_sub_issue", + "request_copilot_review", + "search_code", + "search_issues", + "search_pull_requests", + "search_repositories", + "search_users", + "update_issue", + "update_pull_request", + "update_pull_request_branch" + ], + "metadata": { + "stars": 23700, + "last_updated": "2025-10-18T02:26:51Z" + }, + "repository_url": "https://github.com/github/github-mcp-server", + "tags": [ + "api", + "create", + "fork", + "github", + "list", + "pull-request", + "push", + "repository", + "search", + "update", + "issues" + ], + "image": "ghcr.io/github/github-mcp-server:v0.19.1", + "permissions": { + "network": { + "outbound": { + "allow_host": [ + ".github.com", + ".githubusercontent.com" + ], + "allow_port": [ + 443 + ] + } + } + }, + "env_vars": [ + { + "name": "GITHUB_PERSONAL_ACCESS_TOKEN", + "description": "GitHub personal access token with appropriate permissions", + "required": true, + "secret": true + }, + { + "name": "GITHUB_HOST", + "description": "GitHub Enterprise Server hostname (optional)", + "required": false + }, + { + "name": "GITHUB_TOOLSETS", + "description": "Comma-separated list of toolsets to enable (e.g., 'repos,issues,pull_requests'). If not set, all toolsets are enabled. See the README for available toolsets.", + "required": false + }, + { + "name": "GITHUB_DYNAMIC_TOOLSETS", + "description": "Set to '1' to enable dynamic toolset discovery", + "required": false + }, + { + "name": "GITHUB_READ_ONLY", + "description": "Set to '1' to enable read-only mode, preventing any modifications to GitHub resources", + "required": false + } + ], + "provenance": { + "sigstore_url": "tuf-repo-cdn.sigstore.dev", + "repository_uri": "https://github.com/github/github-mcp-server", + "signer_identity": "/.github/workflows/docker-publish.yml", + "runner_environment": "github-hosted", + "cert_issuer": "https://token.actions.githubusercontent.com" + } +}` + + // Parse ImageMetadata JSON + var imageMetadata registry.ImageMetadata + err := json.Unmarshal([]byte(imageMetadataJSON), &imageMetadata) + require.NoError(t, err, "Should parse ImageMetadata JSON") + + // Log the parsed structure for visual inspection + t.Logf("Parsed ImageMetadata:") + t.Logf(" Description: %s", imageMetadata.Description) + t.Logf(" Image: %s", imageMetadata.Image) + t.Logf(" Status: %s", imageMetadata.Status) + t.Logf(" Tier: %s", imageMetadata.Tier) + t.Logf(" Tools: %d items", len(imageMetadata.Tools)) + t.Logf(" EnvVars: %d items", len(imageMetadata.EnvVars)) + t.Logf(" Tags: %d items", len(imageMetadata.Tags)) + + // Verify parsed data matches expectations + assert.Equal(t, "Provides integration with GitHub's APIs", imageMetadata.Description) + assert.Equal(t, "ghcr.io/github/github-mcp-server:v0.19.1", imageMetadata.Image) + assert.Equal(t, "Active", imageMetadata.Status) + assert.Equal(t, "Official", imageMetadata.Tier) + assert.Equal(t, "stdio", imageMetadata.Transport) + assert.Len(t, imageMetadata.Tools, 46) + assert.Len(t, imageMetadata.EnvVars, 5) + assert.Len(t, imageMetadata.Tags, 11) + assert.NotNil(t, imageMetadata.Permissions) + assert.NotNil(t, imageMetadata.Provenance) + + // Convert to official ServerJSON format + serverJSON, err := ImageMetadataToServerJSON("github", &imageMetadata) + require.NoError(t, err, "Should convert ImageMetadata to ServerJSON") + require.NotNil(t, serverJSON) + + // Marshal to JSON for visual inspection + serverJSONBytes, err := json.MarshalIndent(serverJSON, "", " ") + require.NoError(t, err) + t.Logf("\n\nConverted to Official ServerJSON:\n%s", string(serverJSONBytes)) + + // Verify official format structure + assert.Equal(t, model.CurrentSchemaURL, serverJSON.Schema) + assert.Equal(t, "io.github.stacklok/github", serverJSON.Name) + assert.Equal(t, "Provides integration with GitHub's APIs", serverJSON.Description) + require.Len(t, serverJSON.Packages, 1) + assert.Equal(t, "ghcr.io/github/github-mcp-server:v0.19.1", serverJSON.Packages[0].Identifier) + assert.Len(t, serverJSON.Packages[0].EnvironmentVariables, 5) + + // Verify publisher extensions contain all the extra data + require.NotNil(t, serverJSON.Meta) + require.NotNil(t, serverJSON.Meta.PublisherProvided) + stacklokData, ok := serverJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + require.True(t, ok) + extensions, ok := stacklokData["ghcr.io/github/github-mcp-server:v0.19.1"].(map[string]interface{}) + require.True(t, ok) + + // Verify extensions + assert.Equal(t, "Active", extensions["status"]) + assert.Equal(t, "Official", extensions["tier"]) + assert.NotNil(t, extensions["tools"]) + assert.NotNil(t, extensions["tags"]) + assert.NotNil(t, extensions["metadata"]) + assert.NotNil(t, extensions["permissions"]) + assert.NotNil(t, extensions["provenance"]) + + // Test round-trip: Convert back to ImageMetadata + roundTripImageMetadata, err := ServerJSONToImageMetadata(serverJSON) + require.NoError(t, err, "Should convert ServerJSON back to ImageMetadata") + require.NotNil(t, roundTripImageMetadata) + + // Marshal round-trip result for visual inspection + roundTripBytes, err := json.MarshalIndent(roundTripImageMetadata, "", " ") + require.NoError(t, err) + t.Logf("\n\nRound-trip back to ImageMetadata:\n%s", string(roundTripBytes)) + + // Verify round-trip preserved all data + assert.Equal(t, imageMetadata.Description, roundTripImageMetadata.Description) + assert.Equal(t, imageMetadata.Image, roundTripImageMetadata.Image) + assert.Equal(t, imageMetadata.Status, roundTripImageMetadata.Status) + assert.Equal(t, imageMetadata.Tier, roundTripImageMetadata.Tier) + assert.Equal(t, imageMetadata.Transport, roundTripImageMetadata.Transport) + assert.Equal(t, imageMetadata.RepositoryURL, roundTripImageMetadata.RepositoryURL) + assert.Equal(t, imageMetadata.Tools, roundTripImageMetadata.Tools) + assert.Equal(t, imageMetadata.Tags, roundTripImageMetadata.Tags) + assert.Len(t, roundTripImageMetadata.EnvVars, 5) + + // Verify all 5 env vars + envVarNames := []string{} + for _, ev := range roundTripImageMetadata.EnvVars { + envVarNames = append(envVarNames, ev.Name) + } + assert.Contains(t, envVarNames, "GITHUB_PERSONAL_ACCESS_TOKEN") + assert.Contains(t, envVarNames, "GITHUB_HOST") + assert.Contains(t, envVarNames, "GITHUB_TOOLSETS") + assert.Contains(t, envVarNames, "GITHUB_DYNAMIC_TOOLSETS") + assert.Contains(t, envVarNames, "GITHUB_READ_ONLY") + + // Verify metadata preserved + require.NotNil(t, roundTripImageMetadata.Metadata) + assert.Equal(t, 23700, roundTripImageMetadata.Metadata.Stars) + + // Verify permissions and provenance are preserved through the round-trip + assert.NotNil(t, roundTripImageMetadata.Permissions) + assert.NotNil(t, roundTripImageMetadata.Provenance) +} diff --git a/registry/converters/envvar_extraction_test.go b/registry/converters/envvar_extraction_test.go new file mode 100644 index 0000000..59ec45e --- /dev/null +++ b/registry/converters/envvar_extraction_test.go @@ -0,0 +1,474 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "testing" + + "github.com/modelcontextprotocol/registry/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + types "github.com/stacklok/toolhive-core/registry/types" +) + +// Test extracting environment variables from runtime arguments (-e flags) +func TestExtractEnvFromRuntimeArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []model.Argument + expected []*types.EnvVar + }{ + { + name: "single -e flag with variable reference", + args: []model.Argument{ + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "GITHUB_PERSONAL_ACCESS_TOKEN={token}", + Description: "Set an environment variable in the runtime", + IsRequired: true, + }, + Variables: map[string]model.Input{ + "token": { + IsRequired: true, + IsSecret: true, + Format: "string", + }, + }, + }, + Type: model.ArgumentTypeNamed, + Name: "-e", + }, + }, + expected: []*types.EnvVar{ + { + Name: "GITHUB_PERSONAL_ACCESS_TOKEN", + Description: "Set an environment variable in the runtime", + Required: true, + Secret: true, + }, + }, + }, + { + name: "multiple -e flags", + args: []model.Argument{ + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "API_KEY={key}", + Description: "API key", + IsRequired: true, + }, + Variables: map[string]model.Input{ + "key": { + IsRequired: true, + IsSecret: true, + }, + }, + }, + Type: model.ArgumentTypeNamed, + Name: "-e", + }, + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "DEBUG=true", + Description: "Enable debug mode", + IsRequired: false, + }, + }, + Type: model.ArgumentTypeNamed, + Name: "-e", + }, + }, + expected: []*types.EnvVar{ + { + Name: "API_KEY", + Description: "API key", + Required: true, + Secret: true, + }, + { + Name: "DEBUG", + Description: "Enable debug mode", + Required: false, + Default: "true", + }, + }, + }, + { + name: "--env flag variant", + args: []model.Argument{ + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "TOKEN={token}", + Description: "Auth token", + IsRequired: true, + }, + Variables: map[string]model.Input{ + "token": { + IsRequired: true, + IsSecret: true, + }, + }, + }, + Type: model.ArgumentTypeNamed, + Name: "--env", + }, + }, + expected: []*types.EnvVar{ + { + Name: "TOKEN", + Description: "Auth token", + Required: true, + Secret: true, + }, + }, + }, + { + name: "mixed with non-env arguments", + args: []model.Argument{ + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "run", + }, + }, + Type: model.ArgumentTypePositional, + }, + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "true", + }, + }, + Type: model.ArgumentTypeNamed, + Name: "-i", + }, + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "KEY=value", + Description: "Some key", + }, + }, + Type: model.ArgumentTypeNamed, + Name: "-e", + }, + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "true", + }, + }, + Type: model.ArgumentTypeNamed, + Name: "--rm", + }, + }, + expected: []*types.EnvVar{ + { + Name: "KEY", + Description: "Some key", + Default: "value", + }, + }, + }, + { + name: "no environment arguments", + args: []model.Argument{}, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := extractEnvFromRuntimeArgs(tt.args) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Test parsing environment variable values +func TestParseEnvVarFromValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + description string + variables map[string]model.Input + expected *types.EnvVar + }{ + { + name: "static value", + value: "DEBUG=true", + description: "Enable debug", + variables: nil, + expected: &types.EnvVar{ + Name: "DEBUG", + Description: "Enable debug", + Default: "true", + }, + }, + { + name: "variable reference with metadata", + value: "API_KEY={key}", + description: "API key", + variables: map[string]model.Input{ + "key": { + IsRequired: true, + IsSecret: true, + Default: "default-key", + }, + }, + expected: &types.EnvVar{ + Name: "API_KEY", + Description: "API key", + Required: true, + Secret: true, + Default: "default-key", + }, + }, + { + name: "variable reference without metadata", + value: "TOKEN={token}", + description: "Auth token", + variables: map[string]model.Input{}, + expected: &types.EnvVar{ + Name: "TOKEN", + Description: "Auth token", + }, + }, + { + name: "empty value", + value: "", + description: "Something", + variables: nil, + expected: nil, + }, + { + name: "no equals sign", + value: "INVALID", + description: "Invalid", + variables: nil, + expected: nil, + }, + { + name: "complex value with equals", + value: "CONNECTION_STRING=host=localhost;port=5432", + description: "Database connection", + variables: nil, + expected: &types.EnvVar{ + Name: "CONNECTION_STRING", + Description: "Database connection", + Default: "host=localhost;port=5432", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := parseEnvVarFromValue(tt.value, tt.description, tt.variables) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Test extracting environment variables from both sources +func TestExtractEnvironmentVariables(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pkg model.Package + expected []*types.EnvVar + }{ + { + name: "from environmentVariables field only", + pkg: model.Package{ + EnvironmentVariables: []model.KeyValueInput{ + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "API key", + IsRequired: true, + IsSecret: true, + }, + }, + Name: "API_KEY", + }, + }, + }, + expected: []*types.EnvVar{ + { + Name: "API_KEY", + Description: "API key", + Required: true, + Secret: true, + }, + }, + }, + { + name: "from runtimeArguments only", + pkg: model.Package{ + RuntimeArguments: []model.Argument{ + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "TOKEN={token}", + Description: "Auth token", + IsRequired: true, + }, + Variables: map[string]model.Input{ + "token": { + IsRequired: true, + IsSecret: true, + }, + }, + }, + Type: model.ArgumentTypeNamed, + Name: "-e", + }, + }, + }, + expected: []*types.EnvVar{ + { + Name: "TOKEN", + Description: "Auth token", + Required: true, + Secret: true, + }, + }, + }, + { + name: "from both sources combined", + pkg: model.Package{ + EnvironmentVariables: []model.KeyValueInput{ + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "Variable 1", + IsRequired: true, + }, + }, + Name: "VAR1", + }, + }, + RuntimeArguments: []model.Argument{ + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "VAR2={var2}", + Description: "Variable 2", + IsRequired: true, + }, + Variables: map[string]model.Input{ + "var2": { + IsRequired: true, + IsSecret: true, + }, + }, + }, + Type: model.ArgumentTypeNamed, + Name: "-e", + }, + }, + }, + expected: []*types.EnvVar{ + { + Name: "VAR1", + Description: "Variable 1", + Required: true, + }, + { + Name: "VAR2", + Description: "Variable 2", + Required: true, + Secret: true, + }, + }, + }, + { + name: "empty package", + pkg: model.Package{}, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := extractEnvironmentVariables(tt.pkg) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Integration test with realistic GitHub MCP server data +func TestServerJSONToImageMetadata_GitHubServerEnvVars(t *testing.T) { + t.Parallel() + + // Simulate the GitHub MCP server structure with -e flags + serverJSON := createTestServerJSON() + serverJSON.Name = "io.github.github/github-mcp-server" + serverJSON.Packages[0].RuntimeArguments = []model.Argument{ + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "run", + Description: "The runtime command to execute", + IsRequired: true, + }, + }, + Type: model.ArgumentTypePositional, + }, + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "true", + Description: "Run container in interactive mode", + IsRequired: true, + Format: "boolean", + }, + }, + Type: model.ArgumentTypeNamed, + Name: "-i", + }, + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Value: "GITHUB_PERSONAL_ACCESS_TOKEN={token}", + Description: "Set an environment variable in the runtime", + IsRequired: true, + }, + Variables: map[string]model.Input{ + "token": { + IsRequired: true, + IsSecret: true, + Format: "string", + }, + }, + }, + Type: model.ArgumentTypeNamed, + Name: "-e", + }, + } + + result, err := ServerJSONToImageMetadata(serverJSON) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify environment variable was extracted + require.Len(t, result.EnvVars, 1) + assert.Equal(t, "GITHUB_PERSONAL_ACCESS_TOKEN", result.EnvVars[0].Name) + assert.Equal(t, "Set an environment variable in the runtime", result.EnvVars[0].Description) + assert.True(t, result.EnvVars[0].Required) + assert.True(t, result.EnvVars[0].Secret) +} diff --git a/registry/converters/integration_test.go b/registry/converters/integration_test.go new file mode 100644 index 0000000..2badfcf --- /dev/null +++ b/registry/converters/integration_test.go @@ -0,0 +1,415 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + upstream "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/stretchr/testify/require" + + types "github.com/stacklok/toolhive-core/registry/types" +) + +// ToolHiveRegistry represents the structure of registry.json +type ToolHiveRegistry struct { + Servers map[string]json.RawMessage `json:"servers"` + RemoteServers map[string]json.RawMessage `json:"remote_servers"` +} + +// parseServerEntry parses a server entry as either ImageMetadata or RemoteServerMetadata +func parseServerEntry(data json.RawMessage) (imageMetadata *types.ImageMetadata, remoteMetadata *types.RemoteServerMetadata, err error) { + // Try to detect type by checking for "image" vs "url" field + var typeCheck map[string]interface{} + if err := json.Unmarshal(data, &typeCheck); err != nil { + return nil, nil, err + } + + if _, hasImage := typeCheck["image"]; hasImage { + // It's an ImageMetadata + var img types.ImageMetadata + if err := json.Unmarshal(data, &img); err != nil { + return nil, nil, err + } + return &img, nil, nil + } else if _, hasURL := typeCheck["url"]; hasURL { + // It's a RemoteServerMetadata + var remote types.RemoteServerMetadata + if err := json.Unmarshal(data, &remote); err != nil { + return nil, nil, err + } + return nil, &remote, nil + } + + return nil, nil, fmt.Errorf("unable to determine server type") +} + +// OfficialRegistry represents the structure of official-registry.json +type OfficialRegistry struct { + Data struct { + Servers []upstream.ServerJSON `json:"servers"` + } `json:"data"` +} + +// TestRoundTrip_RealRegistryData tests that we can convert the official registry back to toolhive format +// and that it matches the original registry.json +// Note: This is an integration test that reads from build/ directory, so we don't run it in parallel +// +//nolint:paralleltest // Integration test reads from shared build/ directory +func TestRoundTrip_RealRegistryData(t *testing.T) { + // Skip if running in CI or if files don't exist + officialPath := filepath.Join("..", "..", "..", "build", "official-registry.json") + toolhivePath := filepath.Join("..", "..", "..", "build", "registry.json") + + if _, err := os.Stat(officialPath); os.IsNotExist(err) { + t.Skip("Skipping integration test: official-registry.json not found") + } + if _, err := os.Stat(toolhivePath); os.IsNotExist(err) { + t.Skip("Skipping integration test: registry.json not found") + } + + // Read official registry + officialData, err := os.ReadFile(officialPath) + require.NoError(t, err, "Failed to read official-registry.json") + + var officialRegistry OfficialRegistry + err = json.Unmarshal(officialData, &officialRegistry) + require.NoError(t, err, "Failed to parse official-registry.json") + + // Read toolhive registry + toolhiveData, err := os.ReadFile(toolhivePath) + require.NoError(t, err, "Failed to read registry.json") + + var toolhiveRegistry ToolHiveRegistry + err = json.Unmarshal(toolhiveData, &toolhiveRegistry) + require.NoError(t, err, "Failed to parse registry.json") + + t.Logf("Loaded %d servers from official registry", len(officialRegistry.Data.Servers)) + t.Logf("Loaded %d image servers and %d remote servers from toolhive registry", + len(toolhiveRegistry.Servers), len(toolhiveRegistry.RemoteServers)) + + // Track statistics + stats := struct { + total int + imageServers int + remoteServers int + conversionErrors int + mismatches []string + }{} + + // For each server in official registry, convert back and compare + for _, serverJSON := range officialRegistry.Data.Servers { + stats.total++ + + // Extract simple name from reverse DNS format + simpleName := ExtractServerName(serverJSON.Name) + + t.Run(simpleName, func(t *testing.T) { + // Find corresponding entry in toolhive registry (check both servers and remote_servers) + var originalData json.RawMessage + var exists bool + + // Try servers first + originalData, exists = toolhiveRegistry.Servers[simpleName] + if !exists { + // Try remote_servers + originalData, exists = toolhiveRegistry.RemoteServers[simpleName] + if !exists { + t.Logf("⚠️ Server '%s' not found in toolhive registry (checked both servers and remote_servers)", simpleName) + return + } + } + + // Parse the original entry + originalImage, originalRemote, err := parseServerEntry(originalData) + if err != nil { + t.Errorf("❌ Failed to parse original entry: %v", err) + return + } + + // Determine if it's an image or remote server from official registry + isImage := len(serverJSON.Packages) > 0 + isRemote := len(serverJSON.Remotes) > 0 + + if isImage { + stats.imageServers++ + testImageServerRoundTrip(t, simpleName, &serverJSON, originalImage, &stats) + } else if isRemote { + stats.remoteServers++ + testRemoteServerRoundTrip(t, simpleName, &serverJSON, originalRemote, &stats) + } else { + t.Errorf("❌ Server '%s' has neither packages nor remotes", simpleName) + } + }) + } + + // Print summary + separator := strings.Repeat("=", 80) + t.Logf("\n%s", separator) + t.Logf("INTEGRATION TEST SUMMARY") + t.Logf("%s", separator) + t.Logf("Total servers: %d", stats.total) + t.Logf(" - Image servers: %d", stats.imageServers) + t.Logf(" - Remote servers: %d", stats.remoteServers) + t.Logf("Conversion errors: %d", stats.conversionErrors) + t.Logf("Field mismatches: %d", len(stats.mismatches)) + + if len(stats.mismatches) > 0 { + t.Logf("\nMismatched fields:") + for _, mismatch := range stats.mismatches { + t.Logf(" - %s", mismatch) + } + } + + if stats.conversionErrors == 0 && len(stats.mismatches) == 0 { + t.Logf("\n✅ All servers converted successfully with no mismatches!") + } + t.Logf("%s", separator) +} + +func testImageServerRoundTrip(t *testing.T, name string, serverJSON *upstream.ServerJSON, original *types.ImageMetadata, stats *struct { + total int + imageServers int + remoteServers int + conversionErrors int + mismatches []string +}) { + t.Helper() + if original == nil { + t.Errorf("❌ Original ImageMetadata is nil for '%s'", name) + return + } + + // Convert ServerJSON back to ImageMetadata + converted, err := ServerJSONToImageMetadata(serverJSON) + if err != nil { + t.Errorf("❌ Conversion failed: %v", err) + stats.conversionErrors++ + return + } + + // Compare fields + compareImageMetadata(t, name, original, converted, stats) +} + +func testRemoteServerRoundTrip(t *testing.T, name string, serverJSON *upstream.ServerJSON, original *types.RemoteServerMetadata, stats *struct { + total int + imageServers int + remoteServers int + conversionErrors int + mismatches []string +}) { + t.Helper() + if original == nil { + t.Errorf("❌ Original RemoteServerMetadata is nil for '%s'", name) + return + } + + // Convert ServerJSON back to RemoteServerMetadata + converted, err := ServerJSONToRemoteServerMetadata(serverJSON) + if err != nil { + t.Errorf("❌ Conversion failed: %v", err) + stats.conversionErrors++ + return + } + + // Compare fields + compareRemoteServerMetadata(t, name, original, converted, stats) +} + +func compareImageMetadata(t *testing.T, name string, original, converted *types.ImageMetadata, stats *struct { + total int + imageServers int + remoteServers int + conversionErrors int + mismatches []string +}) { + t.Helper() + // Compare basic fields + if original.Image != converted.Image { + recordMismatch(t, stats, name, "Image", original.Image, converted.Image) + } + if original.Description != converted.Description { + recordMismatch(t, stats, name, "Description", original.Description, converted.Description) + } + if original.Transport != converted.Transport { + recordMismatch(t, stats, name, "Transport", original.Transport, converted.Transport) + } + if original.RepositoryURL != converted.RepositoryURL { + recordMismatch(t, stats, name, "RepositoryURL", original.RepositoryURL, converted.RepositoryURL) + } + if original.Status != converted.Status { + recordMismatch(t, stats, name, "Status", original.Status, converted.Status) + } + if original.Tier != converted.Tier { + recordMismatch(t, stats, name, "Tier", original.Tier, converted.Tier) + } + if original.TargetPort != converted.TargetPort { + recordMismatch(t, stats, name, "TargetPort", original.TargetPort, converted.TargetPort) + } + + // Compare slices + if !stringSlicesEqual(original.Tools, converted.Tools) { + recordMismatch(t, stats, name, "Tools", original.Tools, converted.Tools) + } + if !stringSlicesEqual(original.Tags, converted.Tags) { + recordMismatch(t, stats, name, "Tags", original.Tags, converted.Tags) + } + + // Compare EnvVars + if len(original.EnvVars) != len(converted.EnvVars) { + recordMismatch(t, stats, name, "EnvVars.length", len(original.EnvVars), len(converted.EnvVars)) + } else { + for i := range original.EnvVars { + if !envVarsEqual(original.EnvVars[i], converted.EnvVars[i]) { + recordMismatch(t, stats, name, fmt.Sprintf("EnvVars[%d]", i), original.EnvVars[i], converted.EnvVars[i]) + } + } + } + + // Compare Metadata + if !metadataEqual(original.Metadata, converted.Metadata) { + recordMismatch(t, stats, name, "Metadata", original.Metadata, converted.Metadata) + } + + // Note: Permissions, Provenance, Args are in extensions and may not round-trip perfectly + // We'll log these separately if they differ + if original.Permissions != nil && converted.Permissions == nil { + t.Logf("⚠️ '%s': Permissions not preserved in round-trip", name) + } + if original.Provenance != nil && converted.Provenance == nil { + t.Logf("⚠️ '%s': Provenance not preserved in round-trip", name) + } + if len(original.Args) > 0 && len(converted.Args) == 0 { + t.Logf("⚠️ '%s': Args not preserved in round-trip", name) + } +} + +func compareRemoteServerMetadata(t *testing.T, name string, original, converted *types.RemoteServerMetadata, stats *struct { + total int + imageServers int + remoteServers int + conversionErrors int + mismatches []string +}) { + t.Helper() + // Compare basic fields + if original.URL != converted.URL { + recordMismatch(t, stats, name, "URL", original.URL, converted.URL) + } + if original.Description != converted.Description { + recordMismatch(t, stats, name, "Description", original.Description, converted.Description) + } + if original.Transport != converted.Transport { + recordMismatch(t, stats, name, "Transport", original.Transport, converted.Transport) + } + if original.RepositoryURL != converted.RepositoryURL { + recordMismatch(t, stats, name, "RepositoryURL", original.RepositoryURL, converted.RepositoryURL) + } + if original.Status != converted.Status { + recordMismatch(t, stats, name, "Status", original.Status, converted.Status) + } + if original.Tier != converted.Tier { + recordMismatch(t, stats, name, "Tier", original.Tier, converted.Tier) + } + + // Compare slices + if !stringSlicesEqual(original.Tools, converted.Tools) { + recordMismatch(t, stats, name, "Tools", original.Tools, converted.Tools) + } + if !stringSlicesEqual(original.Tags, converted.Tags) { + recordMismatch(t, stats, name, "Tags", original.Tags, converted.Tags) + } + + // Compare Headers + if len(original.Headers) != len(converted.Headers) { + recordMismatch(t, stats, name, "Headers.length", len(original.Headers), len(converted.Headers)) + } else { + for i := range original.Headers { + if !headersEqual(original.Headers[i], converted.Headers[i]) { + recordMismatch(t, stats, name, fmt.Sprintf("Headers[%d]", i), original.Headers[i], converted.Headers[i]) + } + } + } + + // Compare Metadata + if !metadataEqual(original.Metadata, converted.Metadata) { + recordMismatch(t, stats, name, "Metadata", original.Metadata, converted.Metadata) + } + + // Note: OAuthConfig may not round-trip perfectly + if original.OAuthConfig != nil && converted.OAuthConfig == nil { + t.Logf("⚠️ '%s': OAuthConfig not preserved in round-trip", name) + } +} + +func recordMismatch(t *testing.T, stats *struct { + total int + imageServers int + remoteServers int + conversionErrors int + mismatches []string +}, serverName, field string, original, converted interface{}) { + t.Helper() + msg := fmt.Sprintf("%s.%s: expected %v, got %v", serverName, field, original, converted) + stats.mismatches = append(stats.mismatches, msg) + t.Logf("⚠️ %s", msg) +} + +// Helper comparison functions + +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func envVarsEqual(a, b *types.EnvVar) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.Name == b.Name && + a.Description == b.Description && + a.Required == b.Required && + a.Secret == b.Secret && + a.Default == b.Default +} + +func headersEqual(a, b *types.Header) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.Name == b.Name && + a.Description == b.Description && + a.Required == b.Required && + a.Secret == b.Secret +} + +func metadataEqual(a, b *types.Metadata) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.Stars == b.Stars && + a.LastUpdated == b.LastUpdated +} diff --git a/registry/converters/registry_converters.go b/registry/converters/registry_converters.go new file mode 100644 index 0000000..b2b8339 --- /dev/null +++ b/registry/converters/registry_converters.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "fmt" + + upstreamv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + + registry "github.com/stacklok/toolhive-core/registry/types" +) + +// NewUpstreamRegistryFromToolhiveRegistry creates a UpstreamRegistry from ToolHive Registry. +// This converts ToolHive format to upstream ServerJSON using the converters package. +// Used when ingesting data from ToolHive-format sources (Git, File, API). +func NewUpstreamRegistryFromToolhiveRegistry(toolhiveReg *registry.Registry) (*registry.UpstreamRegistry, error) { + if toolhiveReg == nil { + return nil, fmt.Errorf("toolhive registry cannot be nil") + } + + servers := make([]upstreamv0.ServerJSON, 0, len(toolhiveReg.Servers)+len(toolhiveReg.RemoteServers)) + + // Convert container servers using converters package + for name, imgMeta := range toolhiveReg.Servers { + serverJSON, err := ImageMetadataToServerJSON(name, imgMeta) + if err != nil { + return nil, fmt.Errorf("failed to convert server %s: %w", name, err) + } + servers = append(servers, *serverJSON) + } + + // Convert remote servers using converters package + for name, remoteMeta := range toolhiveReg.RemoteServers { + serverJSON, err := RemoteServerMetadataToServerJSON(name, remoteMeta) + if err != nil { + return nil, fmt.Errorf("failed to convert remote server %s: %w", name, err) + } + servers = append(servers, *serverJSON) + } + + return ®istry.UpstreamRegistry{ + Schema: registry.UpstreamRegistrySchemaURL, + Version: toolhiveReg.Version, + Meta: registry.UpstreamMeta{ + LastUpdated: toolhiveReg.LastUpdated, + }, + Data: registry.UpstreamData{ + Servers: servers, + Groups: []registry.UpstreamGroup{}, + }, + }, nil +} diff --git a/registry/converters/registry_converters_test.go b/registry/converters/registry_converters_test.go new file mode 100644 index 0000000..5d0c92d --- /dev/null +++ b/registry/converters/registry_converters_test.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + registry "github.com/stacklok/toolhive-core/registry/types" +) + +func TestNewUpstreamRegistryFromToolhiveRegistry(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + toolhiveReg *registry.Registry + expectError bool + validate func(*testing.T, *registry.UpstreamRegistry) + }{ + { + name: "successful conversion with container servers", + toolhiveReg: ®istry.Registry{ + Version: "1.0.0", + LastUpdated: "2024-01-01T00:00:00Z", + Servers: map[string]*registry.ImageMetadata{ + "test-server": { + BaseServerMetadata: registry.BaseServerMetadata{ + Name: "test-server", + Description: "A test server", + Tier: "Community", + Status: "Active", + Transport: "stdio", + Tools: []string{"test_tool"}, + }, + Image: "test/image:latest", + }, + }, + RemoteServers: make(map[string]*registry.RemoteServerMetadata), + }, + expectError: false, + validate: func(t *testing.T, sr *registry.UpstreamRegistry) { + t.Helper() + assert.Equal(t, "1.0.0", sr.Version) + assert.Equal(t, "2024-01-01T00:00:00Z", sr.Meta.LastUpdated) + assert.Len(t, sr.Data.Servers, 1) + assert.Contains(t, sr.Data.Servers[0].Name, "test-server") + assert.Equal(t, "A test server", sr.Data.Servers[0].Description) + }, + }, + { + name: "successful conversion with remote servers", + toolhiveReg: ®istry.Registry{ + Version: "1.0.0", + LastUpdated: "2024-01-01T00:00:00Z", + Servers: make(map[string]*registry.ImageMetadata), + RemoteServers: map[string]*registry.RemoteServerMetadata{ + "remote-server": { + BaseServerMetadata: registry.BaseServerMetadata{ + Name: "remote-server", + Description: "A remote server", + Tier: "Community", + Status: "Active", + Transport: "sse", + Tools: []string{"remote_tool"}, + }, + URL: "https://example.com", + }, + }, + }, + expectError: false, + validate: func(t *testing.T, sr *registry.UpstreamRegistry) { + t.Helper() + assert.Len(t, sr.Data.Servers, 1) + assert.Contains(t, sr.Data.Servers[0].Name, "remote-server") + }, + }, + { + name: "empty registry", + toolhiveReg: ®istry.Registry{ + Version: "1.0.0", + LastUpdated: "2024-01-01T00:00:00Z", + Servers: make(map[string]*registry.ImageMetadata), + RemoteServers: make(map[string]*registry.RemoteServerMetadata), + }, + expectError: false, + validate: func(t *testing.T, sr *registry.UpstreamRegistry) { + t.Helper() + assert.Empty(t, sr.Data.Servers) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := NewUpstreamRegistryFromToolhiveRegistry(tt.toolhiveReg) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + if tt.validate != nil { + tt.validate(t, result) + } + } + }) + } +} diff --git a/registry/converters/testdata/README.md b/registry/converters/testdata/README.md new file mode 100644 index 0000000..fb711c8 --- /dev/null +++ b/registry/converters/testdata/README.md @@ -0,0 +1,174 @@ +# Converter Test Fixtures + +This directory contains JSON fixture files for testing the converter functions between ToolHive ImageMetadata/RemoteServerMetadata and official MCP ServerJSON formats. + +## Directory Structure + +``` +testdata/ +├── image_to_server/ # ImageMetadata → ServerJSON conversions +├── server_to_image/ # ServerJSON → ImageMetadata conversions +├── remote_to_server/ # RemoteServerMetadata → ServerJSON conversions +└── server_to_remote/ # ServerJSON → RemoteServerMetadata conversions +``` + +Each directory contains: +- `input_*.json` - Input data for the conversion +- `expected_*.json` - Expected output after conversion + +## Test Coverage + +### Image-based Servers + +**GitHub Server** (`image_to_server/input_github.json`) +- Full production example with 46 tools +- 5 environment variables (including optional ones) +- Permissions and provenance metadata +- Demonstrates complete ToolHive → Official MCP conversion + +**Round-trip** (`server_to_image/`) +- Uses the output from `image_to_server` as input +- Validates bidirectional conversion without data loss +- Ensures all fields are preserved through the conversion cycle + +### Remote Servers + +**Example Remote** (`remote_to_server/input_example.json`) +- SSE transport type +- Multiple headers (required and optional) +- Demonstrates remote server conversion pattern + +**Round-trip** (`server_to_remote/`) +- Validates remote server bidirectional conversion +- Ensures headers and metadata are preserved + +## Usage in Tests + +The fixtures are used by `converters_fixture_test.go`: + +```go +func TestConverters_Fixtures(t *testing.T) { + // Table-driven test that: + // 1. Loads input fixture + // 2. Runs conversion + // 3. Compares with expected output + // 4. Runs additional validation checks +} +``` + +## Adding New Test Cases + +To add a new test case: + +1. **Create input file** in the appropriate directory: + ```bash + # For image-based server + vi testdata/image_to_server/input_myserver.json + ``` + +2. **Generate expected output** using the converter: + ```go + // Example code to generate expected output + imageMetadata := loadFromFile("input_myserver.json") + serverJSON, _ := ImageMetadataToServerJSON("myserver", imageMetadata) + saveToFile("expected_myserver.json", serverJSON) + ``` + +3. **Add test case** to `converters_fixture_test.go`: + ```go + { + name: "ImageMetadata to ServerJSON - MyServer", + fixtureDir: "testdata/image_to_server", + inputFile: "input_myserver.json", + expectedFile: "expected_myserver.json", + serverName: "myserver", + convertFunc: "ImageToServer", + validateFunc: validateImageToServerConversion, + }, + ``` + +## Fixture Format Examples + +### ImageMetadata Format +```json +{ + "description": "Server description", + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": ["tool1", "tool2"], + "image": "ghcr.io/org/server:v1.0.0", + "env_vars": [...], + "permissions": {...}, + "provenance": {...} +} +``` + +### ServerJSON Format +```json +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "name": "io.github.stacklok/server", + "description": "Server description", + "version": "1.0.0", + "packages": [{ + "registryType": "oci", + "identifier": "ghcr.io/org/server:v1.0.0", + "transport": {"type": "stdio"}, + "environmentVariables": [...] + }], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/org/server:v1.0.0": { + "status": "Active", + "tier": "Official", + "tools": [...], + ... + } + } + } + } +} +``` + +### RemoteServerMetadata Format +```json +{ + "description": "Remote server description", + "transport": "sse", + "url": "https://api.example.com/mcp", + "headers": [ + { + "name": "Authorization", + "description": "Auth header", + "required": true, + "secret": true + } + ], + "tools": [...], + "tags": [...] +} +``` + +## Benefits of Fixture-based Testing + +1. **Visual Inspection** - Easy to see exactly what data is being transformed +2. **Maintainability** - Update fixtures without changing test code +3. **Documentation** - Fixtures serve as examples of expected formats +4. **Regression Detection** - Any changes to output format are immediately visible +5. **Multiple Scenarios** - Easy to add edge cases and variants + +## Regenerating Fixtures + +If the converter logic changes and you need to regenerate expected outputs: + +```bash +# Regenerate all expected outputs +go run scripts/regenerate_fixtures.go + +# Or regenerate specific ones +go run scripts/regenerate_fixtures.go --type image_to_server --name github +``` + +(Note: Create the regenerate script if needed) \ No newline at end of file diff --git a/registry/converters/testdata/image_to_server/expected_github.json b/registry/converters/testdata/image_to_server/expected_github.json new file mode 100644 index 0000000..ecb08c3 --- /dev/null +++ b/registry/converters/testdata/image_to_server/expected_github.json @@ -0,0 +1,163 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.stacklok/github", + "title": "GitHub MCP Server", + "description": "Provides integration with GitHub's APIs", + "repository": { + "url": "https://github.com/github/github-mcp-server", + "source": "github" + }, + "version": "1.0.0", + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/github/github-mcp-server:v0.19.1", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "description": "GitHub personal access token with appropriate permissions", + "isRequired": true, + "isSecret": true, + "name": "GITHUB_PERSONAL_ACCESS_TOKEN" + }, + { + "description": "GitHub Enterprise Server hostname (optional)", + "name": "GITHUB_HOST" + }, + { + "description": "Comma-separated list of toolsets to enable (e.g., 'repos,issues,pull_requests'). If not set, all toolsets are enabled. See the README for available toolsets.", + "name": "GITHUB_TOOLSETS" + }, + { + "description": "Set to '1' to enable dynamic toolset discovery", + "name": "GITHUB_DYNAMIC_TOOLSETS" + }, + { + "description": "Set to '1' to enable read-only mode, preventing any modifications to GitHub resources", + "name": "GITHUB_READ_ONLY" + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/github/github-mcp-server:v0.19.1": { + "metadata": { + "last_updated": "2025-10-18T02:26:51Z", + "stars": 23700 + }, + "permissions": { + "network": { + "outbound": { + "allow_host": [ + ".github.com", + ".githubusercontent.com" + ], + "allow_port": [ + 443 + ] + } + } + }, + "provenance": { + "cert_issuer": "https://token.actions.githubusercontent.com", + "repository_uri": "https://github.com/github/github-mcp-server", + "runner_environment": "github-hosted", + "signer_identity": "/.github/workflows/docker-publish.yml", + "sigstore_url": "tuf-repo-cdn.sigstore.dev" + }, + "status": "Active", + "tags": [ + "api", + "create", + "fork", + "github", + "list", + "pull-request", + "push", + "repository", + "search", + "update", + "issues" + ], + "tier": "Official", + "tools": [ + "add_comment_to_pending_review", + "add_issue_comment", + "add_sub_issue", + "assign_copilot_to_issue", + "create_branch", + "create_issue", + "create_or_update_file", + "create_pull_request", + "create_repository", + "delete_file", + "fork_repository", + "get_commit", + "get_file_contents", + "get_issue", + "get_issue_comments", + "get_label", + "get_latest_release", + "get_me", + "get_release_by_tag", + "get_tag", + "get_team_members", + "get_teams", + "list_branches", + "list_commits", + "list_issue_types", + "list_issues", + "list_label", + "list_pull_requests", + "list_releases", + "list_sub_issues", + "list_tags", + "merge_pull_request", + "pull_request_read", + "pull_request_review_write", + "push_files", + "remove_sub_issue", + "reprioritize_sub_issue", + "request_copilot_review", + "search_code", + "search_issues", + "search_pull_requests", + "search_repositories", + "search_users", + "update_issue", + "update_pull_request", + "update_pull_request_branch" + ], + "overview": "# GitHub MCP Server\n\nProvides comprehensive integration with GitHub APIs.", + "tool_definitions": [ + { + "annotations": {}, + "name": "create_issue", + "description": "Create a new issue in a GitHub repository", + "inputSchema": { + "type": "object", + "properties": { + "owner": {"type": "string"}, + "repo": {"type": "string"}, + "title": {"type": "string"} + }, + "required": ["owner", "repo", "title"] + } + } + ], + "docker_tags": ["v0.19.1", "v0.19.0", "latest"], + "proxy_port": 8080, + "custom_metadata": { + "maintainer": "GitHub", + "license": "MIT", + "homepage": "https://github.com/github/github-mcp-server" + } + } + } + } + } +} diff --git a/registry/converters/testdata/image_to_server/input_github.json b/registry/converters/testdata/image_to_server/input_github.json new file mode 100644 index 0000000..e9f3181 --- /dev/null +++ b/registry/converters/testdata/image_to_server/input_github.json @@ -0,0 +1,145 @@ +{ + "description": "Provides integration with GitHub's APIs", + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": [ + "add_comment_to_pending_review", + "add_issue_comment", + "add_sub_issue", + "assign_copilot_to_issue", + "create_branch", + "create_issue", + "create_or_update_file", + "create_pull_request", + "create_repository", + "delete_file", + "fork_repository", + "get_commit", + "get_file_contents", + "get_issue", + "get_issue_comments", + "get_label", + "get_latest_release", + "get_me", + "get_release_by_tag", + "get_tag", + "get_team_members", + "get_teams", + "list_branches", + "list_commits", + "list_issue_types", + "list_issues", + "list_label", + "list_pull_requests", + "list_releases", + "list_sub_issues", + "list_tags", + "merge_pull_request", + "pull_request_read", + "pull_request_review_write", + "push_files", + "remove_sub_issue", + "reprioritize_sub_issue", + "request_copilot_review", + "search_code", + "search_issues", + "search_pull_requests", + "search_repositories", + "search_users", + "update_issue", + "update_pull_request", + "update_pull_request_branch" + ], + "title": "GitHub MCP Server", + "overview": "# GitHub MCP Server\n\nProvides comprehensive integration with GitHub APIs.", + "tool_definitions": [ + { + "name": "create_issue", + "description": "Create a new issue in a GitHub repository", + "inputSchema": { + "type": "object", + "properties": { + "owner": {"type": "string"}, + "repo": {"type": "string"}, + "title": {"type": "string"} + }, + "required": ["owner", "repo", "title"] + } + } + ], + "metadata": { + "stars": 23700, + "last_updated": "2025-10-18T02:26:51Z" + }, + "repository_url": "https://github.com/github/github-mcp-server", + "tags": [ + "api", + "create", + "fork", + "github", + "list", + "pull-request", + "push", + "repository", + "search", + "update", + "issues" + ], + "image": "ghcr.io/github/github-mcp-server:v0.19.1", + "permissions": { + "network": { + "outbound": { + "allow_host": [ + ".github.com", + ".githubusercontent.com" + ], + "allow_port": [ + 443 + ] + } + } + }, + "env_vars": [ + { + "name": "GITHUB_PERSONAL_ACCESS_TOKEN", + "description": "GitHub personal access token with appropriate permissions", + "required": true, + "secret": true + }, + { + "name": "GITHUB_HOST", + "description": "GitHub Enterprise Server hostname (optional)", + "required": false + }, + { + "name": "GITHUB_TOOLSETS", + "description": "Comma-separated list of toolsets to enable (e.g., 'repos,issues,pull_requests'). If not set, all toolsets are enabled. See the README for available toolsets.", + "required": false + }, + { + "name": "GITHUB_DYNAMIC_TOOLSETS", + "description": "Set to '1' to enable dynamic toolset discovery", + "required": false + }, + { + "name": "GITHUB_READ_ONLY", + "description": "Set to '1' to enable read-only mode, preventing any modifications to GitHub resources", + "required": false + } + ], + "provenance": { + "sigstore_url": "tuf-repo-cdn.sigstore.dev", + "repository_uri": "https://github.com/github/github-mcp-server", + "signer_identity": "/.github/workflows/docker-publish.yml", + "runner_environment": "github-hosted", + "cert_issuer": "https://token.actions.githubusercontent.com" + }, + "docker_tags": ["v0.19.1", "v0.19.0", "latest"], + "proxy_port": 8080, + "custom_metadata": { + "maintainer": "GitHub", + "license": "MIT", + "homepage": "https://github.com/github/github-mcp-server" + } +} diff --git a/registry/converters/testdata/remote_to_server/expected_example.json b/registry/converters/testdata/remote_to_server/expected_example.json new file mode 100644 index 0000000..8bf7e50 --- /dev/null +++ b/registry/converters/testdata/remote_to_server/expected_example.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.stacklok/example-remote", + "title": "Example Remote Server", + "description": "Example remote MCP server accessed via SSE", + "repository": { + "url": "https://github.com/example/remote-mcp-server", + "source": "github" + }, + "version": "1.0.0", + "remotes": [ + { + "type": "sse", + "url": "https://api.example.com/mcp", + "headers": [ + { + "description": "Bearer token for API authentication", + "isRequired": true, + "isSecret": true, + "name": "Authorization" + }, + { + "description": "API version to use", + "name": "X-API-Version" + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "https://api.example.com/mcp": { + "env_vars": [ + { + "name": "API_ENDPOINT", + "description": "Base URL for API calls", + "required": false, + "default": "https://api.example.com" + }, + { + "name": "CLIENT_SECRET", + "description": "Client secret for OAuth", + "required": true, + "secret": true + } + ], + "metadata": { + "last_updated": "2025-10-20T10:00:00Z", + "stars": 150 + }, + "oauth_config": { + "issuer": "https://auth.example.com", + "client_id": "example-client", + "scopes": ["openid", "profile"] + }, + "overview": "# Example Remote Server\n\nA demo remote MCP server with SSE transport.", + "tool_definitions": [ + { + "annotations": {}, + "name": "get_data", + "description": "Retrieves data from the remote API", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string"} + }, + "required": ["query"] + } + } + ], + "custom_metadata": { + "provider": "Example Corp", + "api_version": "v2", + "rate_limit": 1000 + }, + "status": "active", + "tags": [ + "remote", + "sse", + "api" + ], + "tier": "Community", + "tools": [ + "get_data", + "send_notification", + "query_api" + ] + } + } + } + } +} diff --git a/registry/converters/testdata/remote_to_server/input_example.json b/registry/converters/testdata/remote_to_server/input_example.json new file mode 100644 index 0000000..bf1700d --- /dev/null +++ b/registry/converters/testdata/remote_to_server/input_example.json @@ -0,0 +1,74 @@ +{ + "title": "Example Remote Server", + "description": "Example remote MCP server accessed via SSE", + "overview": "# Example Remote Server\n\nA demo remote MCP server with SSE transport.", + "tool_definitions": [ + { + "name": "get_data", + "description": "Retrieves data from the remote API", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string"} + }, + "required": ["query"] + } + } + ], + "tier": "Community", + "status": "active", + "transport": "sse", + "tools": [ + "get_data", + "send_notification", + "query_api" + ], + "metadata": { + "stars": 150, + "last_updated": "2025-10-20T10:00:00Z" + }, + "repository_url": "https://github.com/example/remote-mcp-server", + "tags": [ + "remote", + "sse", + "api" + ], + "url": "https://api.example.com/mcp", + "headers": [ + { + "name": "Authorization", + "description": "Bearer token for API authentication", + "required": true, + "secret": true + }, + { + "name": "X-API-Version", + "description": "API version to use", + "required": false + } + ], + "env_vars": [ + { + "name": "API_ENDPOINT", + "description": "Base URL for API calls", + "required": false, + "default": "https://api.example.com" + }, + { + "name": "CLIENT_SECRET", + "description": "Client secret for OAuth", + "required": true, + "secret": true + } + ], + "oauth_config": { + "issuer": "https://auth.example.com", + "client_id": "example-client", + "scopes": ["openid", "profile"] + }, + "custom_metadata": { + "provider": "Example Corp", + "api_version": "v2", + "rate_limit": 1000 + } +} diff --git a/registry/converters/testdata/server_to_image/expected_github.json b/registry/converters/testdata/server_to_image/expected_github.json new file mode 100644 index 0000000..0d78867 --- /dev/null +++ b/registry/converters/testdata/server_to_image/expected_github.json @@ -0,0 +1,147 @@ +{ + "name": "io.github.stacklok/github", + "title": "GitHub MCP Server", + "description": "Provides integration with GitHub's APIs", + "overview": "# GitHub MCP Server\n\nProvides comprehensive integration with GitHub APIs.", + "tool_definitions": [ + { + "annotations": {}, + "name": "create_issue", + "description": "Create a new issue in a GitHub repository", + "inputSchema": { + "type": "object", + "properties": { + "owner": {"type": "string"}, + "repo": {"type": "string"}, + "title": {"type": "string"} + }, + "required": ["owner", "repo", "title"] + } + } + ], + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": [ + "add_comment_to_pending_review", + "add_issue_comment", + "add_sub_issue", + "assign_copilot_to_issue", + "create_branch", + "create_issue", + "create_or_update_file", + "create_pull_request", + "create_repository", + "delete_file", + "fork_repository", + "get_commit", + "get_file_contents", + "get_issue", + "get_issue_comments", + "get_label", + "get_latest_release", + "get_me", + "get_release_by_tag", + "get_tag", + "get_team_members", + "get_teams", + "list_branches", + "list_commits", + "list_issue_types", + "list_issues", + "list_label", + "list_pull_requests", + "list_releases", + "list_sub_issues", + "list_tags", + "merge_pull_request", + "pull_request_read", + "pull_request_review_write", + "push_files", + "remove_sub_issue", + "reprioritize_sub_issue", + "request_copilot_review", + "search_code", + "search_issues", + "search_pull_requests", + "search_repositories", + "search_users", + "update_issue", + "update_pull_request", + "update_pull_request_branch" + ], + "metadata": { + "stars": 23700, + "last_updated": "2025-10-18T02:26:51Z" + }, + "repository_url": "https://github.com/github/github-mcp-server", + "tags": [ + "api", + "create", + "fork", + "github", + "list", + "pull-request", + "push", + "repository", + "search", + "update", + "issues" + ], + "image": "ghcr.io/github/github-mcp-server:v0.19.1", + "permissions": { + "network": { + "outbound": { + "allow_host": [ + ".github.com", + ".githubusercontent.com" + ], + "allow_port": [ + 443 + ] + } + } + }, + "env_vars": [ + { + "name": "GITHUB_PERSONAL_ACCESS_TOKEN", + "description": "GitHub personal access token with appropriate permissions", + "required": true, + "secret": true + }, + { + "name": "GITHUB_HOST", + "description": "GitHub Enterprise Server hostname (optional)", + "required": false + }, + { + "name": "GITHUB_TOOLSETS", + "description": "Comma-separated list of toolsets to enable (e.g., 'repos,issues,pull_requests'). If not set, all toolsets are enabled. See the README for available toolsets.", + "required": false + }, + { + "name": "GITHUB_DYNAMIC_TOOLSETS", + "description": "Set to '1' to enable dynamic toolset discovery", + "required": false + }, + { + "name": "GITHUB_READ_ONLY", + "description": "Set to '1' to enable read-only mode, preventing any modifications to GitHub resources", + "required": false + } + ], + "provenance": { + "sigstore_url": "tuf-repo-cdn.sigstore.dev", + "repository_uri": "https://github.com/github/github-mcp-server", + "signer_identity": "/.github/workflows/docker-publish.yml", + "runner_environment": "github-hosted", + "cert_issuer": "https://token.actions.githubusercontent.com" + }, + "docker_tags": ["v0.19.1", "v0.19.0", "latest"], + "proxy_port": 8080, + "custom_metadata": { + "maintainer": "GitHub", + "license": "MIT", + "homepage": "https://github.com/github/github-mcp-server" + } +} diff --git a/registry/converters/testdata/server_to_image/input_github.json b/registry/converters/testdata/server_to_image/input_github.json new file mode 100644 index 0000000..eba93a6 --- /dev/null +++ b/registry/converters/testdata/server_to_image/input_github.json @@ -0,0 +1,163 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.stacklok/github", + "title": "GitHub MCP Server", + "description": "Provides integration with GitHub's APIs", + "repository": { + "url": "https://github.com/github/github-mcp-server", + "source": "github" + }, + "version": "1.0.0", + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/github/github-mcp-server:v0.19.1", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "description": "GitHub personal access token with appropriate permissions", + "isRequired": true, + "isSecret": true, + "name": "GITHUB_PERSONAL_ACCESS_TOKEN" + }, + { + "description": "GitHub Enterprise Server hostname (optional)", + "name": "GITHUB_HOST" + }, + { + "description": "Comma-separated list of toolsets to enable (e.g., 'repos,issues,pull_requests'). If not set, all toolsets are enabled. See the README for available toolsets.", + "name": "GITHUB_TOOLSETS" + }, + { + "description": "Set to '1' to enable dynamic toolset discovery", + "name": "GITHUB_DYNAMIC_TOOLSETS" + }, + { + "description": "Set to '1' to enable read-only mode, preventing any modifications to GitHub resources", + "name": "GITHUB_READ_ONLY" + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/github/github-mcp-server:v0.19.1": { + "metadata": { + "last_updated": "2025-10-18T02:26:51Z", + "stars": 23700 + }, + "permissions": { + "network": { + "outbound": { + "allow_host": [ + ".github.com", + ".githubusercontent.com" + ], + "allow_port": [ + 443 + ] + } + } + }, + "provenance": { + "cert_issuer": "https://token.actions.githubusercontent.com", + "repository_uri": "https://github.com/github/github-mcp-server", + "runner_environment": "github-hosted", + "signer_identity": "/.github/workflows/docker-publish.yml", + "sigstore_url": "tuf-repo-cdn.sigstore.dev" + }, + "status": "Active", + "tags": [ + "api", + "create", + "fork", + "github", + "list", + "pull-request", + "push", + "repository", + "search", + "update", + "issues" + ], + "tier": "Official", + "tools": [ + "add_comment_to_pending_review", + "add_issue_comment", + "add_sub_issue", + "assign_copilot_to_issue", + "create_branch", + "create_issue", + "create_or_update_file", + "create_pull_request", + "create_repository", + "delete_file", + "fork_repository", + "get_commit", + "get_file_contents", + "get_issue", + "get_issue_comments", + "get_label", + "get_latest_release", + "get_me", + "get_release_by_tag", + "get_tag", + "get_team_members", + "get_teams", + "list_branches", + "list_commits", + "list_issue_types", + "list_issues", + "list_label", + "list_pull_requests", + "list_releases", + "list_sub_issues", + "list_tags", + "merge_pull_request", + "pull_request_read", + "pull_request_review_write", + "push_files", + "remove_sub_issue", + "reprioritize_sub_issue", + "request_copilot_review", + "search_code", + "search_issues", + "search_pull_requests", + "search_repositories", + "search_users", + "update_issue", + "update_pull_request", + "update_pull_request_branch" + ], + "transport": "stdio", + "overview": "# GitHub MCP Server\n\nProvides comprehensive integration with GitHub APIs.", + "tool_definitions": [ + { + "name": "create_issue", + "description": "Create a new issue in a GitHub repository", + "inputSchema": { + "type": "object", + "properties": { + "owner": {"type": "string"}, + "repo": {"type": "string"}, + "title": {"type": "string"} + }, + "required": ["owner", "repo", "title"] + } + } + ], + "docker_tags": ["v0.19.1", "v0.19.0", "latest"], + "proxy_port": 8080, + "custom_metadata": { + "maintainer": "GitHub", + "license": "MIT", + "homepage": "https://github.com/github/github-mcp-server" + } + } + } + } + } +} diff --git a/registry/converters/testdata/server_to_remote/expected_example.json b/registry/converters/testdata/server_to_remote/expected_example.json new file mode 100644 index 0000000..76513ad --- /dev/null +++ b/registry/converters/testdata/server_to_remote/expected_example.json @@ -0,0 +1,76 @@ +{ + "name": "io.github.stacklok/example-remote", + "title": "Example Remote Server", + "description": "Example remote MCP server accessed via SSE", + "overview": "# Example Remote Server\n\nA demo remote MCP server with SSE transport.", + "tool_definitions": [ + { + "annotations": {}, + "name": "get_data", + "description": "Retrieves data from the remote API", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string"} + }, + "required": ["query"] + } + } + ], + "tier": "Community", + "status": "active", + "transport": "sse", + "tools": [ + "get_data", + "send_notification", + "query_api" + ], + "metadata": { + "stars": 150, + "last_updated": "2025-10-20T10:00:00Z" + }, + "repository_url": "https://github.com/example/remote-mcp-server", + "tags": [ + "remote", + "sse", + "api" + ], + "url": "https://api.example.com/mcp", + "headers": [ + { + "name": "Authorization", + "description": "Bearer token for API authentication", + "required": true, + "secret": true + }, + { + "name": "X-API-Version", + "description": "API version to use", + "required": false + } + ], + "env_vars": [ + { + "name": "API_ENDPOINT", + "description": "Base URL for API calls", + "required": false, + "default": "https://api.example.com" + }, + { + "name": "CLIENT_SECRET", + "description": "Client secret for OAuth", + "required": true, + "secret": true + } + ], + "oauth_config": { + "issuer": "https://auth.example.com", + "client_id": "example-client", + "scopes": ["openid", "profile"] + }, + "custom_metadata": { + "provider": "Example Corp", + "api_version": "v2", + "rate_limit": 1000 + } +} diff --git a/registry/converters/testdata/server_to_remote/input_example.json b/registry/converters/testdata/server_to_remote/input_example.json new file mode 100644 index 0000000..c4d1b27 --- /dev/null +++ b/registry/converters/testdata/server_to_remote/input_example.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.stacklok/example-remote", + "title": "Example Remote Server", + "description": "Example remote MCP server accessed via SSE", + "repository": { + "url": "https://github.com/example/remote-mcp-server", + "source": "github" + }, + "version": "1.0.0", + "remotes": [ + { + "type": "sse", + "url": "https://api.example.com/mcp", + "headers": [ + { + "description": "Bearer token for API authentication", + "isRequired": true, + "isSecret": true, + "name": "Authorization" + }, + { + "description": "API version to use", + "name": "X-API-Version" + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "https://api.example.com/mcp": { + "env_vars": [ + { + "name": "API_ENDPOINT", + "description": "Base URL for API calls", + "required": false, + "default": "https://api.example.com" + }, + { + "name": "CLIENT_SECRET", + "description": "Client secret for OAuth", + "required": true, + "secret": true + } + ], + "metadata": { + "last_updated": "2025-10-20T10:00:00Z", + "stars": 150 + }, + "oauth_config": { + "issuer": "https://auth.example.com", + "client_id": "example-client", + "scopes": ["openid", "profile"] + }, + "overview": "# Example Remote Server\n\nA demo remote MCP server with SSE transport.", + "tool_definitions": [ + { + "name": "get_data", + "description": "Retrieves data from the remote API", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string"} + }, + "required": ["query"] + } + } + ], + "custom_metadata": { + "provider": "Example Corp", + "api_version": "v2", + "rate_limit": 1000 + }, + "status": "active", + "tags": [ + "remote", + "sse", + "api" + ], + "tier": "Community", + "tools": [ + "get_data", + "send_notification", + "query_api" + ], + "transport": "sse" + } + } + } + } +} diff --git a/registry/converters/toolhive_to_upstream.go b/registry/converters/toolhive_to_upstream.go new file mode 100644 index 0000000..1a1b0af --- /dev/null +++ b/registry/converters/toolhive_to_upstream.go @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package converters provides bidirectional conversion between toolhive registry formats +// and the upstream MCP (Model Context Protocol) ServerJSON format. +// +// The package supports two conversion directions: +// - toolhive → upstream: ImageMetadata/RemoteServerMetadata → ServerJSON (this file) +// - upstream → toolhive: ServerJSON → ImageMetadata/RemoteServerMetadata (upstream_to_toolhive.go) +// +// Toolhive-specific fields (permissions, provenance, metadata) are stored in the upstream +// format's publisher extensions under "io.github.stacklok", allowing additional metadata +// while maintaining compatibility with the standard MCP registry format. +package converters + +import ( + "encoding/json" + "fmt" + + upstream "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/modelcontextprotocol/registry/pkg/model" + + registry "github.com/stacklok/toolhive-core/registry/types" +) + +// ImageMetadataToServerJSON converts toolhive ImageMetadata to an upstream ServerJSON +// The name parameter is deprecated and should match imageMetadata.Name. It's kept for backward compatibility. +func ImageMetadataToServerJSON(name string, imageMetadata *registry.ImageMetadata) (*upstream.ServerJSON, error) { + if imageMetadata == nil { + return nil, fmt.Errorf("imageMetadata cannot be nil") + } + if name == "" { + return nil, fmt.Errorf("name cannot be empty") + } + + // Use imageMetadata.Name if available (contains canonical identifier), otherwise fall back to parameter + canonicalName := imageMetadata.Name + if canonicalName == "" { + canonicalName = BuildReverseDNSName(name) + } + + // Create ServerJSON with basic fields + serverJSON := &upstream.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: canonicalName, + Title: imageMetadata.Title, + Description: imageMetadata.Description, + Version: "1.0.0", // TODO: Extract from image tag or metadata + } + + // Set repository if available + if imageMetadata.RepositoryURL != "" { + serverJSON.Repository = &model.Repository{ + URL: imageMetadata.RepositoryURL, + Source: "github", // Assume GitHub + } + } + + // Create package + serverJSON.Packages = createPackagesFromImageMetadata(imageMetadata) + + // Create publisher extensions + serverJSON.Meta = &upstream.ServerMeta{ + PublisherProvided: createImageExtensions(imageMetadata), + } + + return serverJSON, nil +} + +// RemoteServerMetadataToServerJSON converts toolhive RemoteServerMetadata to an upstream ServerJSON +// The name parameter is deprecated and should match remoteMetadata.Name. It's kept for backward compatibility. +func RemoteServerMetadataToServerJSON(name string, remoteMetadata *registry.RemoteServerMetadata) (*upstream.ServerJSON, error) { + if remoteMetadata == nil { + return nil, fmt.Errorf("remoteMetadata cannot be nil") + } + if name == "" { + return nil, fmt.Errorf("name cannot be empty") + } + + // Use remoteMetadata.Name if available (contains canonical identifier), otherwise fall back to parameter + canonicalName := remoteMetadata.Name + if canonicalName == "" { + canonicalName = BuildReverseDNSName(name) + } + + // Create ServerJSON with basic fields + serverJSON := &upstream.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: canonicalName, + Title: remoteMetadata.Title, + Description: remoteMetadata.Description, + Version: "1.0.0", // TODO: Version management + } + + // Set repository if available + if remoteMetadata.RepositoryURL != "" { + serverJSON.Repository = &model.Repository{ + URL: remoteMetadata.RepositoryURL, + Source: "github", // Assume GitHub + } + } + + // Create remote + serverJSON.Remotes = createRemotesFromRemoteMetadata(remoteMetadata) + + // Create publisher extensions + serverJSON.Meta = &upstream.ServerMeta{ + PublisherProvided: createRemoteExtensions(remoteMetadata), + } + + return serverJSON, nil +} + +// createPackagesFromImageMetadata creates OCI Package entries from ImageMetadata +func createPackagesFromImageMetadata(imageMetadata *registry.ImageMetadata) []model.Package { + // Convert environment variables + var envVars []model.KeyValueInput + for _, envVar := range imageMetadata.EnvVars { + envVars = append(envVars, model.KeyValueInput{ + Name: envVar.Name, + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: envVar.Description, + IsRequired: envVar.Required, + IsSecret: envVar.Secret, + Default: envVar.Default, + }, + }, + }) + } + + // Determine transport + transportType := imageMetadata.Transport + if transportType == "" { + transportType = model.TransportTypeStdio + } + + transport := model.Transport{ + Type: transportType, + } + + // Add URL for non-stdio transports + // Note: We use localhost as the host because container-based MCP servers run locally + // and are accessed via port forwarding. The actual container may listen on 0.0.0.0, + // but clients connect via localhost on the host machine. + if transportType == model.TransportTypeStreamableHTTP || transportType == model.TransportTypeSSE { + if imageMetadata.TargetPort > 0 { + // Include port in URL if explicitly set + transport.URL = fmt.Sprintf("http://localhost:%d", imageMetadata.TargetPort) + } else { + // No port specified - use URL without port (standard HTTP port 80) + transport.URL = "http://localhost" + } + } + + return []model.Package{{ + RegistryType: model.RegistryTypeOCI, + Identifier: imageMetadata.Image, + EnvironmentVariables: envVars, + Transport: transport, + }} +} + +// createRemotesFromRemoteMetadata creates Transport entries from RemoteServerMetadata +func createRemotesFromRemoteMetadata(remoteMetadata *registry.RemoteServerMetadata) []model.Transport { + // Convert headers + var headers []model.KeyValueInput + for _, header := range remoteMetadata.Headers { + headers = append(headers, model.KeyValueInput{ + Name: header.Name, + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: header.Description, + IsRequired: header.Required, + IsSecret: header.Secret, + Default: header.Default, + Choices: header.Choices, + }, + }, + }) + } + + return []model.Transport{{ + Type: remoteMetadata.Transport, + URL: remoteMetadata.URL, + Headers: headers, + }} +} + +// buildPublisherExtensionsMap wraps a ServerExtensions value in the publisher-provided +// map structure, defaulting Status to "active" if empty. +func buildPublisherExtensionsMap(ext registry.ServerExtensions, innerKey string) map[string]interface{} { + if ext.Status == "" { + ext.Status = "active" + } + return map[string]interface{}{ + registry.ToolHivePublisherNamespace: map[string]interface{}{ + innerKey: serverExtensionsToMap(ext), + }, + } +} + +// createImageExtensions creates publisher extensions map from ImageMetadata +// using the ServerExtensions type to ensure field names stay in sync with the type definition. +func createImageExtensions(imageMetadata *registry.ImageMetadata) map[string]interface{} { + ext := registry.ServerExtensions{ + Status: imageMetadata.Status, + Tier: imageMetadata.Tier, + Tools: imageMetadata.Tools, + Tags: imageMetadata.Tags, + Overview: imageMetadata.Overview, + ToolDefinitions: imageMetadata.ToolDefinitions, + Metadata: imageMetadata.Metadata, + CustomMetadata: imageMetadata.CustomMetadata, + Permissions: imageMetadata.Permissions, + Args: imageMetadata.Args, + Provenance: imageMetadata.Provenance, + DockerTags: imageMetadata.DockerTags, + ProxyPort: imageMetadata.ProxyPort, + } + return buildPublisherExtensionsMap(ext, imageMetadata.Image) +} + +// createRemoteExtensions creates publisher extensions map from RemoteServerMetadata +// using the ServerExtensions type to ensure field names stay in sync with the type definition. +func createRemoteExtensions(remoteMetadata *registry.RemoteServerMetadata) map[string]interface{} { + ext := registry.ServerExtensions{ + Status: remoteMetadata.Status, + Tier: remoteMetadata.Tier, + Tools: remoteMetadata.Tools, + Tags: remoteMetadata.Tags, + Overview: remoteMetadata.Overview, + ToolDefinitions: remoteMetadata.ToolDefinitions, + Metadata: remoteMetadata.Metadata, + CustomMetadata: remoteMetadata.CustomMetadata, + OAuthConfig: remoteMetadata.OAuthConfig, + EnvVars: remoteMetadata.EnvVars, + } + return buildPublisherExtensionsMap(ext, remoteMetadata.URL) +} + +// serverExtensionsToMap converts a ServerExtensions struct to a map[string]interface{} +// by marshaling to JSON and unmarshaling back. This ensures the map keys match +// the struct's json tags exactly. +func serverExtensionsToMap(ext registry.ServerExtensions) map[string]interface{} { + data, err := json.Marshal(ext) + if err != nil { + // Fallback: return a minimal map with just the status + return map[string]interface{}{"status": ext.Status} + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return map[string]interface{}{"status": ext.Status} + } + + return result +} diff --git a/registry/converters/upstream_to_toolhive.go b/registry/converters/upstream_to_toolhive.go new file mode 100644 index 0000000..49f8541 --- /dev/null +++ b/registry/converters/upstream_to_toolhive.go @@ -0,0 +1,382 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package converters provides conversion functions from upstream MCP ServerJSON format +// to toolhive ImageMetadata/RemoteServerMetadata formats. +package converters + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + + upstream "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/modelcontextprotocol/registry/pkg/model" + + registry "github.com/stacklok/toolhive-core/registry/types" +) + +// ServerJSONToImageMetadata converts an upstream ServerJSON (with OCI packages) to toolhive ImageMetadata +// This function only handles OCI packages and will error if there are multiple OCI packages +func ServerJSONToImageMetadata(serverJSON *upstream.ServerJSON) (*registry.ImageMetadata, error) { + if serverJSON == nil { + return nil, fmt.Errorf("serverJSON cannot be nil") + } + + pkg, err := extractSingleOCIPackage(serverJSON) + if err != nil { + return nil, err + } + + imageMetadata := ®istry.ImageMetadata{ + BaseServerMetadata: registry.BaseServerMetadata{ + Name: serverJSON.Name, + Title: serverJSON.Title, + Description: serverJSON.Description, + Transport: pkg.Transport.Type, + }, + Image: pkg.Identifier, // OCI packages store full image ref in Identifier + } + + // Set repository URL + if serverJSON.Repository != nil && serverJSON.Repository.URL != "" { + imageMetadata.RepositoryURL = serverJSON.Repository.URL + } + + // Convert environment variables from both sources + imageMetadata.EnvVars = extractEnvironmentVariables(pkg) + + // Extract target port from transport URL if present + port, err := extractTargetPort(pkg.Transport.URL, serverJSON.Name) + if err != nil { + return nil, err + } + imageMetadata.TargetPort = port + + // Convert PackageArguments to simple Args (priority: structured arguments first) + if len(pkg.PackageArguments) > 0 { + imageMetadata.Args = flattenPackageArguments(pkg.PackageArguments) + } + + // Extract publisher-provided extensions (including Args fallback) + extractImageExtensions(serverJSON, imageMetadata) + + return imageMetadata, nil +} + +// extractSingleOCIPackage validates and extracts the single OCI package from ServerJSON +func extractSingleOCIPackage(serverJSON *upstream.ServerJSON) (model.Package, error) { + if len(serverJSON.Packages) == 0 { + return model.Package{}, fmt.Errorf("server '%s' has no packages (not a container-based server)", serverJSON.Name) + } + + // Filter for OCI packages only + var ociPackages []model.Package + var packageTypes []string + for _, pkg := range serverJSON.Packages { + if pkg.RegistryType == model.RegistryTypeOCI { + ociPackages = append(ociPackages, pkg) + } + packageTypes = append(packageTypes, string(pkg.RegistryType)) + } + + if len(ociPackages) == 0 { + return model.Package{}, fmt.Errorf("server '%s' has no OCI packages (found: %v)", serverJSON.Name, packageTypes) + } + + if len(ociPackages) > 1 { + return model.Package{}, fmt.Errorf("server '%s' has %d OCI packages, expected exactly 1", serverJSON.Name, len(ociPackages)) + } + + return ociPackages[0], nil +} + +// extractEnvironmentVariables extracts environment variables from both sources: +// 1. The direct environmentVariables field (preferred) +// 2. The -e/--env flags in runtimeArguments (Docker CLI pattern) +func extractEnvironmentVariables(pkg model.Package) []*registry.EnvVar { + var envVars []*registry.EnvVar + + // First, extract from the dedicated environmentVariables field + envVars = append(envVars, convertEnvironmentVariables(pkg.EnvironmentVariables)...) + + // Second, extract from -e/--env flags in runtimeArguments + envVars = append(envVars, extractEnvFromRuntimeArgs(pkg.RuntimeArguments)...) + + return envVars +} + +// convertEnvironmentVariables converts model.KeyValueInput to registry.EnvVar +func convertEnvironmentVariables(envVars []model.KeyValueInput) []*registry.EnvVar { + if len(envVars) == 0 { + return nil + } + + result := make([]*registry.EnvVar, 0, len(envVars)) + for _, envVar := range envVars { + result = append(result, ®istry.EnvVar{ + Name: envVar.Name, + Description: envVar.Description, + Required: envVar.IsRequired, + Secret: envVar.IsSecret, + Default: envVar.Default, + }) + } + return result +} + +// extractEnvFromRuntimeArgs extracts environment variables from -e/--env flags in runtime arguments +// This handles the Docker CLI pattern where env vars are specified as: -e KEY=value or --env KEY=value +func extractEnvFromRuntimeArgs(args []model.Argument) []*registry.EnvVar { + var result []*registry.EnvVar + + for _, arg := range args { + // Skip if not a named argument with -e or --env + if arg.Type != model.ArgumentTypeNamed { + continue + } + if arg.Name != "-e" && arg.Name != "--env" { + continue + } + + // Parse the environment variable from the value + // Format: KEY=value or KEY={variableName} + envVar := parseEnvVarFromValue(arg.Value, arg.Description, arg.Variables) + if envVar != nil { + envVar.Required = arg.IsRequired + result = append(result, envVar) + } + } + + return result +} + +// parseEnvVarFromValue parses an environment variable definition from a value string +// Handles formats like: KEY=value, KEY={varName}, etc. +func parseEnvVarFromValue(value, description string, variables map[string]model.Input) *registry.EnvVar { + if value == "" { + return nil + } + + // Find the = separator + eqIdx := -1 + for i, ch := range value { + if ch == '=' { + eqIdx = i + break + } + } + + if eqIdx == -1 { + return nil // No = found, invalid format + } + + name := value[:eqIdx] + valuePart := value[eqIdx+1:] + + envVar := ®istry.EnvVar{ + Name: name, + Description: description, + } + + // Check if the value contains a variable reference like {token} + if len(valuePart) > 2 && valuePart[0] == '{' && valuePart[len(valuePart)-1] == '}' { + varName := valuePart[1 : len(valuePart)-1] + if varDef, ok := variables[varName]; ok { + envVar.Required = varDef.IsRequired + envVar.Secret = varDef.IsSecret + if varDef.Default != "" { + envVar.Default = varDef.Default + } + } + } else { + // Static value provided + envVar.Default = valuePart + } + + return envVar +} + +// extractTargetPort extracts the port number from a transport URL. +// Returns an error if the URL or port cannot be parsed. +func extractTargetPort(transportURL, serverName string) (int, error) { + if transportURL == "" { + return 0, nil + } + + parsedURL, err := url.Parse(transportURL) + if err != nil { + return 0, fmt.Errorf("server '%s': failed to parse transport URL '%s': %w", serverName, transportURL, err) + } + + if parsedURL.Port() == "" { + return 0, nil + } + + port, err := strconv.Atoi(parsedURL.Port()) + if err != nil { + return 0, fmt.Errorf("server '%s': failed to parse port from URL '%s': %w", serverName, transportURL, err) + } + + return port, nil +} + +// ServerJSONToRemoteServerMetadata converts an upstream ServerJSON (with remotes) to toolhive RemoteServerMetadata +// This function extracts remote server data and reconstructs RemoteServerMetadata format +func ServerJSONToRemoteServerMetadata(serverJSON *upstream.ServerJSON) (*registry.RemoteServerMetadata, error) { + if serverJSON == nil { + return nil, fmt.Errorf("serverJSON cannot be nil") + } + + if len(serverJSON.Remotes) == 0 { + return nil, fmt.Errorf("server '%s' has no remotes (not a remote server)", serverJSON.Name) + } + + remote := serverJSON.Remotes[0] // Use first remote + + remoteMetadata := ®istry.RemoteServerMetadata{ + BaseServerMetadata: registry.BaseServerMetadata{ + Name: serverJSON.Name, + Title: serverJSON.Title, + Description: serverJSON.Description, + Transport: remote.Type, + }, + URL: remote.URL, + } + + // Set repository URL + if serverJSON.Repository != nil && serverJSON.Repository.URL != "" { + remoteMetadata.RepositoryURL = serverJSON.Repository.URL + } + + // Convert headers + if len(remote.Headers) > 0 { + remoteMetadata.Headers = make([]*registry.Header, 0, len(remote.Headers)) + for _, header := range remote.Headers { + remoteMetadata.Headers = append(remoteMetadata.Headers, ®istry.Header{ + Name: header.Name, + Description: header.Description, + Required: header.IsRequired, + Secret: header.IsSecret, + Default: header.Default, + Choices: header.Choices, + }) + } + } + + // Extract publisher-provided extensions + extractRemoteExtensions(serverJSON, remoteMetadata) + + return remoteMetadata, nil +} + +// applyBaseExtensions copies the shared fields from ServerExtensions into a BaseServerMetadata. +func applyBaseExtensions(ext *registry.ServerExtensions, base *registry.BaseServerMetadata) { + base.Status = ext.Status + base.Tier = ext.Tier + base.Tools = ext.Tools + base.Tags = ext.Tags + base.Overview = ext.Overview + base.ToolDefinitions = ext.ToolDefinitions + base.Metadata = ext.Metadata + base.CustomMetadata = ext.CustomMetadata +} + +// extractImageExtensions extracts publisher-provided extensions into ImageMetadata +// using the ServerExtensions type to ensure field names stay in sync with the type definition. +func extractImageExtensions(serverJSON *upstream.ServerJSON, imageMetadata *registry.ImageMetadata) { + ext := getStacklokServerExtensions(serverJSON) + if ext == nil { + return + } + + applyBaseExtensions(ext, &imageMetadata.BaseServerMetadata) + imageMetadata.Permissions = ext.Permissions + imageMetadata.Provenance = ext.Provenance + imageMetadata.DockerTags = ext.DockerTags + imageMetadata.ProxyPort = ext.ProxyPort + + // Args from PackageArguments take priority over extension args + if len(imageMetadata.Args) == 0 { + imageMetadata.Args = ext.Args + } +} + +// extractRemoteExtensions extracts publisher-provided extensions into RemoteServerMetadata +// using the ServerExtensions type to ensure field names stay in sync with the type definition. +func extractRemoteExtensions(serverJSON *upstream.ServerJSON, remoteMetadata *registry.RemoteServerMetadata) { + ext := getStacklokServerExtensions(serverJSON) + if ext == nil { + return + } + + applyBaseExtensions(ext, &remoteMetadata.BaseServerMetadata) + remoteMetadata.OAuthConfig = ext.OAuthConfig + remoteMetadata.EnvVars = ext.EnvVars +} + +// getStacklokServerExtensions retrieves and deserializes the first stacklok extension data +// from ServerJSON into a ServerExtensions struct. +func getStacklokServerExtensions(serverJSON *upstream.ServerJSON) *registry.ServerExtensions { + extensions := getStacklokExtensionsMap(serverJSON) + if extensions == nil { + return nil + } + + return remarshalToType[*registry.ServerExtensions](extensions) +} + +// getStacklokExtensionsMap retrieves the first stacklok extension data from ServerJSON as a raw map. +func getStacklokExtensionsMap(serverJSON *upstream.ServerJSON) map[string]interface{} { + if serverJSON.Meta == nil || serverJSON.Meta.PublisherProvided == nil { + return nil + } + + stacklokData, ok := serverJSON.Meta.PublisherProvided[registry.ToolHivePublisherNamespace].(map[string]interface{}) + if !ok { + return nil + } + + // Return first extension data (keyed by image reference or URL) + for _, extensionsData := range stacklokData { + if extensions, ok := extensionsData.(map[string]interface{}); ok { + return extensions + } + } + return nil +} + +// remarshalToType converts an interface{} value to a specific type using JSON marshaling +// This is useful for deserializing complex nested structures from extensions +func remarshalToType[T any](data interface{}) T { + var result T + + // Marshal to JSON + jsonData, err := json.Marshal(data) + if err != nil { + return result // Return zero value on error + } + + // Unmarshal into target type + _ = json.Unmarshal(jsonData, &result) // Ignore error, return zero value if fails + + return result +} + +// flattenPackageArguments converts structured PackageArguments to simple string Args +// This provides better interoperability when importing from upstream sources +func flattenPackageArguments(args []model.Argument) []string { + var result []string + for _, arg := range args { + // Add the argument name/flag if present + if arg.Name != "" { + result = append(result, arg.Name) + } + // Add the value if present (for named args with values or positional args) + if arg.Value != "" { + result = append(result, arg.Value) + } + } + return result +} diff --git a/registry/converters/utils.go b/registry/converters/utils.go new file mode 100644 index 0000000..4ab7929 --- /dev/null +++ b/registry/converters/utils.go @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package converters provides utility functions for conversion between upstream and toolhive formats. +package converters + +import ( + "strings" +) + +// ExtractServerName extracts the simple server name from a reverse-DNS format name +// Example: "io.github.stacklok/fetch" -> "fetch" +func ExtractServerName(reverseDNSName string) string { + parts := strings.Split(reverseDNSName, "/") + if len(parts) == 2 { + return parts[1] + } + return reverseDNSName +} + +// BuildReverseDNSName builds a reverse-DNS format name from a simple name +// Example: "fetch" -> "io.github.stacklok/fetch" +func BuildReverseDNSName(simpleName string) string { + if strings.Contains(simpleName, "/") { + return simpleName // Already in reverse-DNS format + } + return "io.github.stacklok/" + simpleName +} diff --git a/registry/types/data/publisher-provided.schema.json b/registry/types/data/publisher-provided.schema.json new file mode 100644 index 0000000..6313117 --- /dev/null +++ b/registry/types/data/publisher-provided.schema.json @@ -0,0 +1,427 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/publisher-provided.schema.json", + "title": "ToolHive Publisher-Provided Extensions Schema", + "description": "Schema for ToolHive-specific metadata placed under _meta['io.modelcontextprotocol.registry/publisher-provided'] in MCP server definitions. This schema defines the structure of publisher extensions that ToolHive adds to servers in the upstream MCP registry format.", + "type": "object", + "properties": { + "io.github.stacklok": { + "type": "object", + "description": "ToolHive publisher namespace containing server-specific extensions keyed by identifier (OCI image reference for container servers, URL for remote servers)", + "additionalProperties": { + "$ref": "#/definitions/server_extensions" + } + } + }, + "additionalProperties": { + "type": "object", + "description": "Other publisher namespaces may contain arbitrary extensions" + }, + "definitions": { + "server_extensions": { + "type": "object", + "description": "Extensions for MCP servers. Container servers (keyed by OCI image reference) may use permissions, args, provenance, docker_tags, and proxy_port. Remote servers (keyed by URL) may use oauth_config and env_vars. All servers share status, tier, tools, tags, metadata, and custom_metadata.", + "required": ["status"], + "properties": { + "status": { + "type": "string", + "description": "Current status of the server", + "enum": ["active", "deprecated", "Active", "Deprecated"], + "default": "active" + }, + "tier": { + "type": "string", + "description": "Tier classification of the server", + "enum": ["Official", "Community"] + }, + "tools": { + "type": "array", + "description": "List of tool names provided by this MCP server", + "items": { + "type": "string", + "pattern": "^[\\w-]+$" + }, + "uniqueItems": true + }, + "tags": { + "type": "array", + "description": "Categorization tags for search and filtering", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*[a-z0-9]$" + }, + "uniqueItems": true + }, + "overview": { + "type": "string", + "description": "Longer Markdown-formatted description for web display. Unlike the upstream description (limited to 100 chars), this supports full Markdown and is intended for rich rendering on catalog pages." + }, + "tool_definitions": { + "type": "array", + "description": "Full MCP Tool definitions describing the tools available from this server, including name, description, inputSchema, and annotations", + "items": { + "$ref": "#/definitions/tool_definition" + } + }, + "metadata": { + "$ref": "#/definitions/metadata" + }, + "permissions": { + "description": "Security permissions for container-based servers", + "$ref": "#/definitions/permissions" + }, + "args": { + "type": "array", + "description": "Default command-line arguments for container-based servers", + "items": { + "type": "string" + } + }, + "provenance": { + "description": "Supply chain provenance for container-based servers", + "$ref": "#/definitions/provenance" + }, + "docker_tags": { + "type": "array", + "description": "Available Docker tags for container-based servers", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "proxy_port": { + "type": "integer", + "description": "HTTP proxy port for container-based servers", + "minimum": 1, + "maximum": 65535 + }, + "oauth_config": { + "description": "OAuth/OIDC configuration for remote servers", + "$ref": "#/definitions/oauth_config" + }, + "env_vars": { + "type": "array", + "description": "Environment variables for remote server client configuration", + "items": { + "$ref": "#/definitions/environment_variable" + } + }, + "custom_metadata": { + "type": "object", + "description": "Custom user-defined metadata", + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "metadata": { + "type": "object", + "description": "Popularity, activity metrics, and Kubernetes-specific metadata for the MCP server", + "properties": { + "stars": { + "type": "number", + "description": "Number of repository stars", + "minimum": 0 + }, + "last_updated": { + "type": "string", + "description": "Timestamp when the metadata was last updated, in RFC3339 format", + "format": "date-time" + }, + "kubernetes": { + "$ref": "#/definitions/kubernetes_metadata" + } + }, + "additionalProperties": false + }, + "kubernetes_metadata": { + "type": "object", + "description": "Kubernetes-specific metadata for MCP servers deployed in Kubernetes clusters", + "properties": { + "kind": { + "type": "string", + "description": "Kubernetes resource kind (e.g., MCPServer, VirtualMCPServer, MCPRemoteProxy)", + "examples": ["MCPServer", "VirtualMCPServer", "MCPRemoteProxy"] + }, + "namespace": { + "type": "string", + "description": "Kubernetes namespace where the resource is deployed", + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" + }, + "name": { + "type": "string", + "description": "Kubernetes resource name", + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" + }, + "uid": { + "type": "string", + "description": "Kubernetes resource UID", + "format": "uuid" + }, + "image": { + "type": "string", + "description": "Container image used by the Kubernetes workload (applicable to MCPServer)" + }, + "transport": { + "type": "string", + "description": "Transport type configured for the Kubernetes workload (applicable to MCPServer)", + "enum": ["stdio", "sse", "streamable-http", "http"] + } + }, + "additionalProperties": false + }, + "permissions": { + "type": "object", + "description": "Security permissions applied to the MCP server container", + "properties": { + "name": { + "type": "string", + "description": "Name of the permission profile" + }, + "network": { + "$ref": "#/definitions/network_permissions" + }, + "read": { + "type": "array", + "description": "File system paths the server needs read access to (will be mounted from the host)", + "items": { + "type": "string", + "pattern": "^(/[^/\\0]+)+/?$" + }, + "uniqueItems": true + }, + "write": { + "type": "array", + "description": "File system paths the server needs write access to (will be mounted from the host)", + "items": { + "type": "string", + "pattern": "^(/[^/\\0]+)+/?$" + }, + "uniqueItems": true + }, + "privileged": { + "type": "boolean", + "description": "Whether the container should run in privileged mode", + "default": false + } + }, + "additionalProperties": false + }, + "network_permissions": { + "type": "object", + "description": "Network access permissions for the MCP server", + "properties": { + "outbound": { + "$ref": "#/definitions/outbound_permissions" + } + }, + "additionalProperties": false + }, + "outbound_permissions": { + "type": "object", + "description": "Outbound network access permissions", + "properties": { + "allow_host": { + "type": "array", + "description": "Allowed hostnames or domain patterns for outbound connections", + "items": { + "type": "string", + "anyOf": [ + { + "format": "hostname" + }, + { + "pattern": "^\\.[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$" + } + ] + }, + "uniqueItems": true + }, + "allow_port": { + "type": "array", + "description": "Allowed port numbers for outbound connections", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "uniqueItems": true + }, + "insecure_allow_all": { + "type": "boolean", + "description": "Whether to allow all outbound connections (insecure, use with caution)", + "default": false + } + }, + "additionalProperties": false + }, + "provenance": { + "type": "object", + "description": "Software supply chain provenance information for verified servers", + "properties": { + "sigstore_url": { + "type": "string", + "description": "Sigstore TUF repository host for provenance verification", + "format": "hostname", + "examples": ["tuf-repo.github.com", "tuf-repo-cdn.sigstore.dev"] + }, + "repository_uri": { + "type": "string", + "description": "Repository URI used for provenance verification", + "format": "uri" + }, + "repository_ref": { + "type": "string", + "description": "Repository reference used for provenance verification" + }, + "signer_identity": { + "type": "string", + "description": "Identity of the signer for provenance verification" + }, + "runner_environment": { + "type": "string", + "description": "Build environment where the server was built", + "examples": ["github-hosted", "gitlab-hosted", "self-hosted"] + }, + "cert_issuer": { + "type": "string", + "description": "Certificate issuer for provenance verification", + "format": "uri", + "examples": ["https://token.actions.githubusercontent.com"] + }, + "attestation": { + "$ref": "#/definitions/verified_attestation" + } + }, + "additionalProperties": false + }, + "verified_attestation": { + "type": "object", + "description": "Verified attestation information", + "properties": { + "predicate_type": { + "type": "string", + "description": "Type of the attestation predicate", + "format": "uri", + "examples": [ + "https://slsa.dev/provenance/v0.2", + "https://slsa.dev/provenance/v1" + ] + }, + "predicate": { + "description": "Attestation predicate data" + } + }, + "additionalProperties": false + }, + "oauth_config": { + "type": "object", + "description": "OAuth/OIDC configuration for remote server authentication", + "properties": { + "issuer": { + "type": "string", + "description": "OAuth/OIDC issuer URL for OIDC discovery", + "format": "uri" + }, + "authorize_url": { + "type": "string", + "description": "OAuth authorization endpoint URL (for non-OIDC OAuth)", + "format": "uri" + }, + "token_url": { + "type": "string", + "description": "OAuth token endpoint URL (for non-OIDC OAuth)", + "format": "uri" + }, + "client_id": { + "type": "string", + "description": "OAuth client ID for authentication" + }, + "scopes": { + "type": "array", + "description": "OAuth scopes to request", + "items": { + "type": "string" + } + }, + "use_pkce": { + "type": "boolean", + "description": "Whether to use PKCE for the OAuth flow", + "default": true + }, + "oauth_params": { + "type": "object", + "description": "Additional OAuth parameters to include in the authorization request (server-specific parameters like 'prompt', 'response_mode', etc.)", + "additionalProperties": { + "type": "string" + } + }, + "callback_port": { + "type": "integer", + "description": "Specific port to use for the OAuth callback server", + "minimum": 1, + "maximum": 65535 + }, + "resource": { + "type": "string", + "description": "OAuth 2.0 resource indicator (RFC 8707)" + } + }, + "additionalProperties": false + }, + "environment_variable": { + "type": "object", + "description": "Environment variable definition for MCP server configuration", + "properties": { + "name": { + "type": "string", + "description": "Environment variable name", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "description": { + "type": "string", + "description": "Human-readable explanation of the variable's purpose" + }, + "required": { + "type": "boolean", + "description": "Whether this environment variable is required for the server to function", + "default": false + }, + "secret": { + "type": "boolean", + "description": "Whether this environment variable contains sensitive information", + "default": false + }, + "default": { + "type": "string", + "description": "Default value if the environment variable is not provided" + } + }, + "additionalProperties": false + }, + "tool_definition": { + "type": "object", + "description": "An MCP Tool definition describing a tool available from this server", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "description": "The name of the tool" + }, + "description": { + "type": "string", + "description": "A human-readable description of the tool" + }, + "inputSchema": { + "type": "object", + "description": "A JSON Schema object defining the expected parameters for the tool" + }, + "annotations": { + "type": "object", + "description": "Optional properties describing tool behavior (e.g., readOnlyHint, destructiveHint)" + } + }, + "additionalProperties": true + } + } +} diff --git a/registry/types/data/skill.schema.json b/registry/types/data/skill.schema.json new file mode 100644 index 0000000..31c528d --- /dev/null +++ b/registry/types/data/skill.schema.json @@ -0,0 +1,180 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/skill.schema.json", + "title": "ToolHive Skill Schema", + "description": "Schema for a ToolHive skill entry in the registry", + "type": "object", + "required": [ + "namespace", + "name", + "description", + "version" + ], + "properties": { + "namespace": { + "type": "string", + "description": "Ownership scope using reverse-DNS pattern (e.g. io.github.stacklok)", + "minLength": 3, + "maxLength": 128 + }, + "name": { + "type": "string", + "description": "Skill identifier in lowercase alphanumeric with hyphens", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$" + }, + "description": { + "type": "string", + "description": "Human-readable explanation of the skill", + "minLength": 1, + "maxLength": 1024 + }, + "version": { + "type": "string", + "description": "Version identifier (semantic version or commit hash)", + "minLength": 1 + }, + "status": { + "type": "string", + "description": "Current status of the skill", + "enum": ["active", "deprecated", "archived"], + "default": "active" + }, + "title": { + "type": "string", + "description": "Human-readable display title", + "maxLength": 100 + }, + "license": { + "type": "string", + "description": "SPDX license identifier", + "maxLength": 256 + }, + "compatibility": { + "type": "string", + "description": "Environment requirements for the skill", + "maxLength": 500 + }, + "allowedTools": { + "type": "array", + "description": "Pre-approved tools the skill may use (experimental)", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "repository": { + "$ref": "#/$defs/skill_repository" + }, + "icons": { + "type": "array", + "description": "Display icons for the skill", + "items": { + "$ref": "#/$defs/skill_icon" + } + }, + "packages": { + "type": "array", + "description": "Distribution packages (OCI or Git)", + "items": { + "$ref": "#/$defs/skill_package" + } + }, + "metadata": { + "type": "object", + "description": "Official metadata from the SKILL.md file", + "additionalProperties": true + }, + "_meta": { + "type": "object", + "description": "Extended metadata with reverse-DNS namespaced keys", + "additionalProperties": true + } + }, + "additionalProperties": false, + "$defs": { + "skill_package": { + "type": "object", + "description": "Distribution package reference (OCI or Git)", + "required": ["registryType"], + "properties": { + "registryType": { + "type": "string", + "description": "Package registry type", + "enum": ["oci", "git"] + }, + "identifier": { + "type": "string", + "description": "OCI image reference" + }, + "digest": { + "type": "string", + "description": "Content digest (sha256 format)" + }, + "mediaType": { + "type": "string", + "description": "Media type of the package" + }, + "url": { + "type": "string", + "description": "Git repository URL", + "format": "uri" + }, + "ref": { + "type": "string", + "description": "Git branch or tag reference" + }, + "commit": { + "type": "string", + "description": "Git commit SHA" + }, + "subfolder": { + "type": "string", + "description": "Path within the repository" + } + }, + "additionalProperties": false + }, + "skill_icon": { + "type": "object", + "description": "Display icon for the skill", + "required": ["src"], + "properties": { + "src": { + "type": "string", + "description": "Icon source URL or path" + }, + "size": { + "type": "string", + "description": "Icon dimensions" + }, + "type": { + "type": "string", + "description": "Icon media type" + }, + "label": { + "type": "string", + "description": "Accessibility label for the icon" + } + }, + "additionalProperties": false + }, + "skill_repository": { + "type": "object", + "description": "Source repository metadata", + "properties": { + "url": { + "type": "string", + "description": "Repository URL", + "format": "uri" + }, + "type": { + "type": "string", + "description": "Repository type (e.g. git)" + } + }, + "additionalProperties": false + } + } +} diff --git a/registry/types/data/toolhive-legacy-registry.schema.json b/registry/types/data/toolhive-legacy-registry.schema.json new file mode 100644 index 0000000..54694cd --- /dev/null +++ b/registry/types/data/toolhive-legacy-registry.schema.json @@ -0,0 +1,652 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", + "title": "ToolHive MCP Server Registry Schema", + "description": "JSON Schema for the ToolHive MCP server registry. This schema validates the structure and content of registry.json entries for MCP servers. See docs/registry/management.md and docs/registry/heuristics.md for inclusion criteria and management processes.", + "type": "object", + "required": ["last_updated", "servers", "version"], + "properties": { + "last_updated": { + "type": "string", + "description": "Timestamp when the registry was last updated, in RFC3339 format", + "format": "date-time" + }, + "servers": { + "type": "object", + "description": "Collection of MCP server entries indexed by server name", + "patternProperties": { + "^[a-z0-9][a-z0-9-]+[a-z0-9]$": { + "$ref": "#/definitions/server" + } + }, + "additionalProperties": false + }, + "remote_servers": { + "type": "object", + "description": "Collection of remote MCP server entries indexed by server name", + "patternProperties": { + "^[a-z0-9][a-z0-9-]+[a-z0-9]$": { + "$ref": "#/definitions/remote_server" + } + }, + "additionalProperties": false + }, + "groups": { + "type": "array", + "description": "Collection of group definitions containing related MCP servers", + "items": { + "$ref": "#/definitions/group" + } + }, + "version": { + "type": "string", + "description": "Registry schema version", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + } + }, + "definitions": { + "server": { + "type": "object", + "description": "MCP server entry definition", + "required": [ + "description", + "image", + "status", + "tier", + "tools", + "transport" + ], + "properties": { + "args": { + "type": "array", + "description": "Default command-line arguments passed to the MCP server container", + "items": { + "type": "string" + }, + "default": [] + }, + "custom_metadata": { + "type": "object", + "description": "Custom user-defined metadata for the MCP server, primarily for custom registries", + "additionalProperties": true + }, + "description": { + "type": "string", + "description": "Human-readable description of the server's purpose and functionality", + "minLength": 10, + "maxLength": 500 + }, + "docker_tags": { + "type": "array", + "description": "Available Docker tags for this server image", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "env_vars": { + "type": "array", + "description": "Environment variables that can be passed to the server", + "items": { + "$ref": "#/definitions/environment_variable" + } + }, + "image": { + "type": "string", + "description": "Container image reference for the MCP server", + "pattern": "^[a-z0-9]([a-z0-9._-]*[a-z0-9])?(:[0-9]+)?(/[a-z0-9]([a-z0-9._-]*[a-z0-9])?)*(:([a-zA-Z0-9][a-zA-Z0-9._-]*))?$", + "examples": [ + "mcp/fetch:latest", + "ghcr.io/github/github-mcp-server:latest", + "mcr.microsoft.com/playwright/mcp", + "example.com:5000/team/my-app:2.0" + ] + }, + "metadata": { + "description": "Additional information about the server such as popularity metrics", + "$ref": "#/definitions/metadata" + }, + "name": { + "type": "string", + "description": "Identifier for the MCP server, used when referencing the server in commands (auto-generated from the object key)" + }, + "title": { + "type": "string", + "description": "Optional human-readable display name for the server. If not provided, the name field is used for display." + }, + "overview": { + "type": "string", + "description": "Longer Markdown-formatted description for web display. Unlike the description field (limited to 500 chars), this supports full Markdown and is intended for rich rendering on catalog pages." + }, + "permissions": { + "description": "Security profile and access permissions for the server", + "$ref": "#/definitions/permissions" + }, + "provenance": { + "description": "Verification and signing metadata", + "$ref": "#/definitions/provenance" + }, + "repository_url": { + "type": "string", + "description": "URL of the source code repository for the server", + "format": "uri" + }, + "status": { + "type": "string", + "description": "Current status of the server (Active or Deprecated)", + "enum": ["Active", "Deprecated"] + }, + "tags": { + "type": "array", + "description": "Categorization tags for search and filtering", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*[a-z0-9]$" + }, + "minItems": 1, + "uniqueItems": true + }, + "target_port": { + "type": "integer", + "description": "Port for the container to expose (applicable to SSE and Streamable HTTP transports)", + "minimum": 1, + "maximum": 65535 + }, + "proxy_port": { + "type": "integer", + "description": "Port for the HTTP proxy to listen on (host port). If not specified, a random available port will be assigned", + "minimum": 1, + "maximum": 65535 + }, + "tier": { + "type": "string", + "description": "Tier classification of the server, (Official or Community)", + "enum": ["Official", "Community"] + }, + "tools": { + "type": "array", + "description": "List of tool names provided by this MCP server", + "items": { + "type": "string", + "pattern": "^[\\w-]+$" + }, + "minItems": 1, + "uniqueItems": true + }, + "tool_definitions": { + "type": "array", + "description": "Full MCP Tool definitions describing the tools available from this server, including name, description, inputSchema, and annotations", + "items": { + "type": "object" + } + }, + "transport": { + "type": "string", + "description": "Communication transport protocol used by the MCP server", + "enum": ["stdio", "sse", "streamable-http"], + "default": "stdio" + } + }, + "additionalProperties": false + }, + "environment_variable": { + "type": "object", + "description": "Environment variable definition for MCP server configuration", + "required": ["name", "description", "required"], + "properties": { + "name": { + "type": "string", + "description": "Environment variable name (e.g., API_KEY)", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "description": { + "type": "string", + "description": "Human-readable explanation of the variable's purpose", + "minLength": 5, + "maxLength": 200 + }, + "required": { + "type": "boolean", + "description": "Whether this environment variable is required for the server to function", + "default": false + }, + "secret": { + "type": "boolean", + "description": "Whether this environment variable contains sensitive information that should be stored as a secret", + "default": false + }, + "default": { + "type": "string", + "description": "Value to use if the environment variable is not explicitly provided (only used for non-required variables)" + } + }, + "additionalProperties": false + }, + "permissions": { + "type": "object", + "description": "Security permissions applied to the MCP server", + "required": [], + "properties": { + "network": { + "$ref": "#/definitions/network_permissions" + }, + "read": { + "type": "array", + "description": "File system paths the server needs read access to (will be mounted from the host)", + "items": { + "type": "string", + "pattern": "^(/[^/\\0]+)+/?$" + }, + "uniqueItems": true, + "default": [] + }, + "write": { + "type": "array", + "description": "File system paths the server needs write access to (will be mounted from the host)", + "items": { + "type": "string", + "pattern": "^(/[^/\\0]+)+/?$" + }, + "uniqueItems": true, + "default": [] + }, + "privileged": { + "type": "boolean", + "description": "Whether the container should run in privileged mode. When true, the container has access to all host devices and capabilities. Use with extreme caution as this removes most security isolation.", + "default": false + } + }, + "additionalProperties": false + }, + "network_permissions": { + "type": "object", + "description": "Network access permissions for the MCP server", + "required": [], + "properties": { + "outbound": { + "$ref": "#/definitions/outbound_permissions" + } + }, + "additionalProperties": false + }, + "outbound_permissions": { + "type": "object", + "description": "Outbound network access permissions", + "required": [], + "properties": { + "allow_host": { + "type": "array", + "description": "Allowed hostnames or domain patterns for outbound connections", + "items": { + "type": "string", + "anyOf": [ + { + "format": "hostname" + }, + { + "pattern": "^\\.[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$" + } + ] + }, + "uniqueItems": true, + "default": [] + }, + "allow_port": { + "type": "array", + "description": "Allowed port numbers for outbound connections", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "uniqueItems": true, + "default": [] + }, + "insecure_allow_all": { + "type": "boolean", + "description": "Whether to allow all outbound connections (insecure, use with caution)", + "default": false + } + }, + "additionalProperties": false + }, + "metadata": { + "type": "object", + "description": "Metadata about the MCP server from external sources", + "properties": { + "last_updated": { + "type": "string", + "description": "Timestamp when the metadata was last updated, in RFC3339 format", + "format": "date-time" + }, + "pulls": { + "type": "integer", + "description": "Number of container image pulls", + "minimum": 0 + }, + "stars": { + "type": "integer", + "description": "Number of repository stars", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "provenance": { + "type": "object", + "description": "Software supply chain provenance information for verified servers", + "properties": { + "cert_issuer": { + "type": "string", + "description": "Certificate issuer for provenance verification", + "format": "uri", + "examples": ["https://token.actions.githubusercontent.com"] + }, + "repository_uri": { + "type": "string", + "description": "Repository URI used for provenance verification", + "format": "uri" + }, + "repository_ref": { + "type": "string", + "description": "Repository reference used for provenance verification" + }, + "runner_environment": { + "type": "string", + "description": "Build environment where the server was built", + "examples": ["github-hosted", "gitlab-hosted", "self-hosted"] + }, + "signer_identity": { + "type": "string", + "description": "Identity of the signer for provenance verification" + }, + "sigstore_url": { + "type": "string", + "description": "Sigstore TUF repository host for provenance verification", + "format": "hostname", + "default": "tuf-repo-cdn.sigstore.dev", + "examples": ["tuf-repo.github.com", "tuf-repo-cdn.sigstore.dev"] + }, + "attestation": { + "description": "Verified attestation information", + "$ref": "#/definitions/verified_attestation" + } + }, + "additionalProperties": false + }, + "verified_attestation": { + "type": "object", + "description": "Verified attestation information", + "properties": { + "predicate_type": { + "type": "string", + "description": "Type of the attestation predicate", + "format": "uri", + "examples": [ + "https://slsa.dev/provenance/v0.2", + "https://slsa.dev/provenance/v1" + ] + }, + "predicate": { + "description": "Attestation predicate data" + } + }, + "additionalProperties": false + }, + "header": { + "type": "object", + "description": "HTTP header definition for remote MCP server authentication", + "required": ["name", "description", "required"], + "properties": { + "name": { + "type": "string", + "description": "Header name (e.g., X-API-Key, Authorization)", + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*$" + }, + "description": { + "type": "string", + "description": "Human-readable explanation of the header's purpose", + "minLength": 5, + "maxLength": 200 + }, + "required": { + "type": "boolean", + "description": "Whether this header is required for the server to function", + "default": false + }, + "secret": { + "type": "boolean", + "description": "Whether this header contains sensitive information that should be stored as a secret", + "default": false + }, + "default": { + "type": "string", + "description": "Value to use if the header is not explicitly provided (only used for non-required headers)" + }, + "choices": { + "type": "array", + "description": "List of valid values for the header", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "oauth_config": { + "type": "object", + "description": "OAuth/OIDC configuration for remote server authentication", + "properties": { + "issuer": { + "type": "string", + "description": "OAuth/OIDC issuer URL for OIDC discovery", + "format": "uri" + }, + "authorize_url": { + "type": "string", + "description": "OAuth authorization endpoint URL (for non-OIDC OAuth)", + "format": "uri" + }, + "token_url": { + "type": "string", + "description": "OAuth token endpoint URL (for non-OIDC OAuth)", + "format": "uri" + }, + "client_id": { + "type": "string", + "description": "OAuth client ID for authentication" + }, + "scopes": { + "type": "array", + "description": "OAuth scopes to request", + "items": { + "type": "string" + } + }, + "use_pkce": { + "type": "boolean", + "description": "Whether to use PKCE for the OAuth flow", + "default": true + }, + "oauth_params": { + "type": "object", + "description": "Additional OAuth parameters to include in the authorization request (server-specific parameters like 'prompt', 'response_mode', etc.)", + "additionalProperties": { + "type": "string" + } + }, + "callback_port": { + "type": "integer", + "description": "Specific port to use for the OAuth callback server", + "minimum": 1, + "maximum": 65535 + }, + "resource": { + "type": "string", + "description": "OAuth 2.0 resource indicator (RFC 8707)" + } + }, + "additionalProperties": false + }, + "remote_server": { + "type": "object", + "description": "Remote MCP server entry definition accessed via HTTP/HTTPS", + "required": [ + "url", + "description", + "status", + "tier", + "tools", + "transport" + ], + "properties": { + "name": { + "type": "string", + "description": "Identifier for the remote MCP server (auto-generated from the object key)" + }, + "title": { + "type": "string", + "description": "Optional human-readable display name for the server. If not provided, the name field is used for display." + }, + "overview": { + "type": "string", + "description": "Longer Markdown-formatted description for web display. Unlike the description field (limited to 500 chars), this supports full Markdown and is intended for rich rendering on catalog pages." + }, + "url": { + "type": "string", + "description": "Endpoint URL for the remote MCP server", + "format": "uri", + "examples": [ + "https://api.example.com/mcp", + "https://mcp-server.example.com/sse", + "http://localhost:8080/stream" + ] + }, + "description": { + "type": "string", + "description": "Human-readable description of the server's purpose and functionality", + "minLength": 10, + "maxLength": 500 + }, + "tier": { + "type": "string", + "description": "Tier classification of the server (Official or Community)", + "enum": ["Official", "Community"] + }, + "status": { + "type": "string", + "description": "Current status of the server (Active or Deprecated)", + "enum": ["Active", "Deprecated"] + }, + "transport": { + "type": "string", + "description": "Communication transport protocol used by the remote MCP server", + "enum": ["sse", "streamable-http"], + "default": "sse" + }, + "tools": { + "type": "array", + "description": "List of tool names provided by this MCP server", + "items": { + "type": "string", + "pattern": "^[\\w-]+$" + }, + "minItems": 1, + "uniqueItems": true + }, + "tool_definitions": { + "type": "array", + "description": "Full MCP Tool definitions describing the tools available from this server, including name, description, inputSchema, and annotations", + "items": { + "type": "object" + } + }, + "headers": { + "type": "array", + "description": "HTTP headers for authentication to the remote server", + "items": { + "$ref": "#/definitions/header" + } + }, + "oauth_config": { + "description": "OAuth/OIDC configuration for authentication", + "$ref": "#/definitions/oauth_config" + }, + "env_vars": { + "type": "array", + "description": "Environment variables for client-side configuration", + "items": { + "$ref": "#/definitions/environment_variable" + } + }, + "metadata": { + "description": "Additional information about the server", + "$ref": "#/definitions/metadata" + }, + "repository_url": { + "type": "string", + "description": "URL of the source code repository for the server", + "format": "uri" + }, + "tags": { + "type": "array", + "description": "Categorization tags for search and filtering", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*[a-z0-9]$" + }, + "minItems": 1, + "uniqueItems": true + }, + "custom_metadata": { + "type": "object", + "description": "Custom user-defined metadata for the remote MCP server", + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "group": { + "type": "object", + "description": "Group definition containing related MCP servers that can be deployed together", + "required": ["name", "description"], + "properties": { + "name": { + "type": "string", + "description": "Identifier for the group, used when referencing the group in commands", + "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "description": "Human-readable description of the group's purpose and functionality", + "minLength": 10, + "maxLength": 500 + }, + "servers": { + "type": "object", + "description": "Collection of MCP server entries within this group indexed by server name", + "patternProperties": { + "^[a-z0-9][a-z0-9-]+[a-z0-9]$": { + "$ref": "#/definitions/server" + } + }, + "additionalProperties": false + }, + "remote_servers": { + "type": "object", + "description": "Collection of remote MCP server entries within this group indexed by server name", + "patternProperties": { + "^[a-z0-9][a-z0-9-]+[a-z0-9]$": { + "$ref": "#/definitions/remote_server" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } +} diff --git a/registry/types/data/upstream-registry.schema.json b/registry/types/data/upstream-registry.schema.json new file mode 100644 index 0000000..7202863 --- /dev/null +++ b/registry/types/data/upstream-registry.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/upstream-registry.schema.json", + "title": "ToolHive Registry Schema", + "description": "Schema for ToolHive registry format using official MCP server schema", + "type": "object", + "required": [ + "version", + "meta", + "data" + ], + "properties": { + "$schema": { + "type": "string", + "format": "uri", + "description": "JSON Schema URI for this registry format" + }, + "version": { + "type": "string", + "description": "Schema version of the registry", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "example": "1.0.0" + }, + "meta": { + "type": "object", + "required": [ + "last_updated" + ], + "properties": { + "last_updated": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the registry was last updated, in RFC3339 format", + "example": "2024-01-15T10:30:00Z" + } + } + }, + "data": { + "type": "object", + "required": [ + "servers" + ], + "properties": { + "servers": { + "type": "array", + "description": "Array of MCP servers using the official MCP server schema", + "items": { + "$ref": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json" + } + }, + "groups": { + "type": "array", + "description": "Groups of related MCP servers", + "items": { + "type": "object", + "required": [ + "name", + "description", + "servers" + ], + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the group" + }, + "description": { + "type": "string", + "description": "Description of the group's purpose" + }, + "servers": { + "type": "array", + "description": "Array of servers in this group", + "items": { + "$ref": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json" + } + } + } + } + }, + "skills": { + "type": "array", + "description": "Array of skills in the registry", + "items": { + "$ref": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/skill.schema.json" + } + } + } + } + } +} \ No newline at end of file diff --git a/registry/types/publisher_provided_types.go b/registry/types/publisher_provided_types.go new file mode 100644 index 0000000..0ff92b7 --- /dev/null +++ b/registry/types/publisher_provided_types.go @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "github.com/mark3labs/mcp-go/mcp" + + "github.com/stacklok/toolhive-core/permissions" +) + +// ServerExtensions represents the ToolHive-specific extensions for an MCP server +// in the publisher-provided metadata format. +// +// This structure is used in the _meta["io.modelcontextprotocol.registry/publisher-provided"] +// section of the upstream MCP registry format, keyed by server identifier: +// - For container servers: keyed by OCI image reference (e.g., "ghcr.io/org/image:tag") +// - For remote servers: keyed by URL (e.g., "https://api.example.com/mcp") +// +// Container servers may use: Status, Tier, Tools, Tags, Metadata, CustomMetadata, +// Permissions, Args, Provenance, DockerTags, ProxyPort, ToolDefinitions +// +// Remote servers may use: Status, Tier, Tools, Tags, Metadata, CustomMetadata, +// OAuthConfig, EnvVars, ToolDefinitions +type ServerExtensions struct { + // Status indicates whether the server is active or deprecated (required) + Status string `json:"status" yaml:"status"` + // Tier represents the classification level (e.g., "Official", "Community") + Tier string `json:"tier,omitempty" yaml:"tier,omitempty"` + // Tools lists the tool names provided by this MCP server + Tools []string `json:"tools,omitempty" yaml:"tools,omitempty"` + // Tags are categorization labels for search and filtering + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` + // Overview is a longer Markdown-formatted description for web display. + // Unlike the upstream description (limited to 100 chars), this supports + // full Markdown and is intended for rich rendering on catalog pages. + Overview string `json:"overview,omitempty" yaml:"overview,omitempty"` + // Metadata contains popularity metrics and optional Kubernetes-specific metadata. + // The Kubernetes metadata is only populated when: + // - The server is served from ToolHive Registry Server + // - The server was auto-discovered from a Kubernetes deployment + // - The Kubernetes resource has the required registry annotations + Metadata *Metadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + // CustomMetadata allows for additional user-defined metadata + CustomMetadata map[string]any `json:"custom_metadata,omitempty" yaml:"custom_metadata,omitempty"` + + // Container-specific fields (only for servers keyed by OCI image reference) + + // Permissions defines security permissions for container-based servers + Permissions *permissions.Profile `json:"permissions,omitempty" yaml:"permissions,omitempty"` + // Args are default command-line arguments for container-based servers + Args []string `json:"args,omitempty" yaml:"args,omitempty"` + // Provenance contains supply chain provenance information for container-based servers + Provenance *Provenance `json:"provenance,omitempty" yaml:"provenance,omitempty"` + // DockerTags lists available Docker tags for container-based servers + DockerTags []string `json:"docker_tags,omitempty" yaml:"docker_tags,omitempty"` + // ProxyPort is the HTTP proxy port for container-based servers (1-65535) + ProxyPort int `json:"proxy_port,omitempty" yaml:"proxy_port,omitempty"` + + // Remote server-specific fields (only for servers keyed by URL) + + // OAuthConfig defines OAuth/OIDC configuration for remote servers + OAuthConfig *OAuthConfig `json:"oauth_config,omitempty" yaml:"oauth_config,omitempty"` + // EnvVars defines environment variables for remote server client configuration + EnvVars []*EnvVar `json:"env_vars,omitempty" yaml:"env_vars,omitempty"` + + // Optional tool definitions (MCP protocol) + + // ToolDefinitions contains an array of MCP Tool definitions that describe the tools + // available from this server. This field can be populated by: + // - ToolHive Registry Server (extracted from Kubernetes annotations) + // - Registry publishers (to pre-declare available tools) + // - Any other source that wants to advertise tool capabilities + // The array contains Tool objects as defined in the MCP specification. + ToolDefinitions []mcp.Tool `json:"tool_definitions,omitempty" yaml:"tool_definitions,omitempty"` +} + +// ToolHivePublisherNamespace is the publisher namespace used by ToolHive in the +// publisher-provided extensions: "io.github.stacklok" +const ToolHivePublisherNamespace = "io.github.stacklok" + +// PublisherProvidedKey is the key used in the _meta object for publisher-provided extensions +const PublisherProvidedKey = "io.modelcontextprotocol.registry/publisher-provided" diff --git a/registry/types/registry_types.go b/registry/types/registry_types.go new file mode 100644 index 0000000..4de2efd --- /dev/null +++ b/registry/types/registry_types.go @@ -0,0 +1,540 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package registry contains the core type definitions for the MCP registry system. +package registry + +import ( + "slices" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "gopkg.in/yaml.v3" + + "github.com/stacklok/toolhive-core/permissions" +) + +// Updates to the registry schema should be reflected in the JSON schema file located at +// registry/types/data/toolhive-legacy-registry.schema.json. +// The schema is used for validation and documentation purposes. +// +// The embedded registry.json is automatically validated against the schema during tests. +// See registry/types/schema_validation_test.go for the validation implementation. + +// Group represents a collection of related MCP servers that can be deployed together +type Group struct { + // Name is the identifier for the group, used when referencing the group in commands + Name string `json:"name" yaml:"name"` + // Description is a human-readable description of the group's purpose and functionality + Description string `json:"description" yaml:"description"` + // Servers is a map of server names to their corresponding server definitions within this group + Servers map[string]*ImageMetadata `json:"servers,omitempty" yaml:"servers,omitempty"` + // RemoteServers is a map of server names to their corresponding remote server definitions within this group + RemoteServers map[string]*RemoteServerMetadata `json:"remote_servers,omitempty" yaml:"remote_servers,omitempty"` +} + +// Registry represents the top-level structure of the MCP registry +type Registry struct { + // Version is the schema version of the registry + Version string `json:"version" yaml:"version"` + // LastUpdated is the timestamp when the registry was last updated, in RFC3339 format + LastUpdated string `json:"last_updated" yaml:"last_updated"` + // Servers is a map of server names to their corresponding server definitions + Servers map[string]*ImageMetadata `json:"servers" yaml:"servers"` + // RemoteServers is a map of server names to their corresponding remote server definitions + // These are MCP servers accessed via HTTP/HTTPS using the thv proxy command + RemoteServers map[string]*RemoteServerMetadata `json:"remote_servers,omitempty" yaml:"remote_servers,omitempty"` + // Groups is a slice of group definitions containing related MCP servers + Groups []*Group `json:"groups,omitempty" yaml:"groups,omitempty"` +} + +// BaseServerMetadata contains common fields shared between container and remote MCP servers +type BaseServerMetadata struct { + // Name is the identifier for the MCP server, used when referencing the server in commands + // If not provided, it will be auto-generated from the registry key + Name string `json:"name,omitempty" yaml:"name,omitempty"` + // Title is an optional human-readable display name for the server. + // If not provided, the Name field is used for display purposes. + Title string `json:"title,omitempty" yaml:"title,omitempty"` + // Description is a human-readable description of the server's purpose and functionality + Description string `json:"description" yaml:"description"` + // Tier represents the tier classification level of the server, e.g., "Official" or "Community" + Tier string `json:"tier" yaml:"tier"` + // Status indicates whether the server is currently active or deprecated + Status string `json:"status" yaml:"status"` + // Transport defines the communication protocol for the server + // For containers: stdio, sse, or streamable-http + // For remote servers: sse or streamable-http (stdio not supported) + Transport string `json:"transport" yaml:"transport"` + // Tools is a list of tool names provided by this MCP server + Tools []string `json:"tools" yaml:"tools"` + // Metadata contains additional information about the server such as popularity metrics + Metadata *Metadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + // RepositoryURL is the URL to the source code repository for the server + RepositoryURL string `json:"repository_url,omitempty" yaml:"repository_url,omitempty"` + // Tags are categorization labels for the server to aid in discovery and filtering + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` + // Overview is a longer Markdown-formatted description for web display. + // Unlike the Description field (limited to 500 chars), this supports + // full Markdown and is intended for rich rendering on catalog pages. + Overview string `json:"overview,omitempty" yaml:"overview,omitempty"` + // ToolDefinitions contains full MCP Tool definitions describing the tools + // available from this server, including name, description, inputSchema, and annotations. + ToolDefinitions []mcp.Tool `json:"tool_definitions,omitempty" yaml:"tool_definitions,omitempty" swaggerignore:"true"` + // CustomMetadata allows for additional user-defined metadata + CustomMetadata map[string]any `json:"custom_metadata,omitempty" yaml:"custom_metadata,omitempty"` +} + +// ImageMetadata represents the metadata for an MCP server image stored in our registry. +type ImageMetadata struct { + BaseServerMetadata + // Image is the Docker image reference for the MCP server + Image string `json:"image" yaml:"image"` + // TargetPort is the port for the container to expose (only applicable to SSE and Streamable HTTP transports) + TargetPort int `json:"target_port,omitempty" yaml:"target_port,omitempty"` + // ProxyPort is the port for the HTTP proxy to listen on (host port) + // If not specified, a random available port will be assigned + ProxyPort int `json:"proxy_port,omitempty" yaml:"proxy_port,omitempty"` + // Permissions defines the security profile and access permissions for the server + Permissions *permissions.Profile `json:"permissions,omitempty" yaml:"permissions,omitempty"` + // EnvVars defines environment variables that can be passed to the server + EnvVars []*EnvVar `json:"env_vars,omitempty" yaml:"env_vars,omitempty"` + // Args are the default command-line arguments to pass to the MCP server container. + // These arguments will be used only if no command-line arguments are provided by the user. + // If the user provides arguments, they will override these defaults. + Args []string `json:"args,omitempty" yaml:"args,omitempty"` + // DockerTags lists the available Docker tags for this server image + DockerTags []string `json:"docker_tags,omitempty" yaml:"docker_tags,omitempty"` + // Provenance contains verification and signing metadata + Provenance *Provenance `json:"provenance,omitempty" yaml:"provenance,omitempty"` +} + +// Provenance contains metadata about the image's provenance and signing status +type Provenance struct { + SigstoreURL string `json:"sigstore_url" yaml:"sigstore_url"` + RepositoryURI string `json:"repository_uri" yaml:"repository_uri"` + RepositoryRef string `json:"repository_ref,omitempty" yaml:"repository_ref,omitempty"` + SignerIdentity string `json:"signer_identity" yaml:"signer_identity"` + RunnerEnvironment string `json:"runner_environment" yaml:"runner_environment"` + CertIssuer string `json:"cert_issuer" yaml:"cert_issuer"` + Attestation *VerifiedAttestation `json:"attestation,omitempty" yaml:"attestation,omitempty"` +} + +// VerifiedAttestation represents the verified attestation information +type VerifiedAttestation struct { + PredicateType string `json:"predicate_type,omitempty" yaml:"predicate_type,omitempty"` + Predicate any `json:"predicate,omitempty" yaml:"predicate,omitempty"` +} + +// EnvVar represents an environment variable for an MCP server +type EnvVar struct { + // Name is the environment variable name (e.g., API_KEY) + Name string `json:"name" yaml:"name"` + // Description is a human-readable explanation of the variable's purpose + Description string `json:"description" yaml:"description"` + // Required indicates whether this environment variable must be provided + // If true and not provided via command line or secrets, the user will be prompted for a value + Required bool `json:"required" yaml:"required"` + // Default is the value to use if the environment variable is not explicitly provided + // Only used for non-required variables + Default string `json:"default,omitempty" yaml:"default,omitempty"` + // Secret indicates whether this environment variable contains sensitive information + // If true, the value will be stored as a secret rather than as a plain environment variable + Secret bool `json:"secret,omitempty" yaml:"secret,omitempty"` +} + +// Header represents an HTTP header for remote MCP server authentication +type Header struct { + // Name is the header name (e.g., X-API-Key, Authorization) + Name string `json:"name" yaml:"name"` + // Description is a human-readable explanation of the header's purpose + Description string `json:"description" yaml:"description"` + // Required indicates whether this header must be provided + // If true and not provided via command line or secrets, the user will be prompted for a value + Required bool `json:"required" yaml:"required"` + // Default is the value to use if the header is not explicitly provided + // Only used for non-required headers + Default string `json:"default,omitempty" yaml:"default,omitempty"` + // Secret indicates whether this header contains sensitive information + // If true, the value will be stored as a secret rather than as plain text + Secret bool `json:"secret,omitempty" yaml:"secret,omitempty"` + // Choices provides a list of valid values for the header (optional) + Choices []string `json:"choices,omitempty" yaml:"choices,omitempty"` +} + +// OAuthConfig represents OAuth/OIDC configuration for remote server authentication +type OAuthConfig struct { + // Issuer is the OAuth/OIDC issuer URL (e.g., https://accounts.google.com) + // Used for OIDC discovery to find authorization and token endpoints + Issuer string `json:"issuer,omitempty" yaml:"issuer,omitempty"` + // AuthorizeURL is the OAuth authorization endpoint URL + // Used for non-OIDC OAuth flows when issuer is not provided + AuthorizeURL string `json:"authorize_url,omitempty" yaml:"authorize_url,omitempty"` + // TokenURL is the OAuth token endpoint URL + // Used for non-OIDC OAuth flows when issuer is not provided + TokenURL string `json:"token_url,omitempty" yaml:"token_url,omitempty"` + // ClientID is the OAuth client ID for authentication + ClientID string `json:"client_id,omitempty" yaml:"client_id,omitempty"` + // Scopes are the OAuth scopes to request + // If not specified, defaults to ["openid", "profile", "email"] for OIDC + Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"` + // UsePKCE indicates whether to use PKCE for the OAuth flow + // Defaults to true for enhanced security + UsePKCE bool `json:"use_pkce,omitempty" yaml:"use_pkce,omitempty"` + // OAuthParams contains additional OAuth parameters to include in the authorization request + // These are server-specific parameters like "prompt", "response_mode", etc. + OAuthParams map[string]string `json:"oauth_params,omitempty" yaml:"oauth_params,omitempty"` + // CallbackPort is the specific port to use for the OAuth callback server + // If not specified, a random available port will be used + CallbackPort int `json:"callback_port,omitempty" yaml:"callback_port,omitempty"` + // Resource is the OAuth 2.0 resource indicator (RFC 8707) + Resource string `json:"resource,omitempty" yaml:"resource,omitempty"` +} + +// RemoteServerMetadata represents the metadata for a remote MCP server accessed via HTTP/HTTPS. +// Remote servers are accessed through the thv proxy command which handles authentication and tunneling. +type RemoteServerMetadata struct { + BaseServerMetadata + // URL is the endpoint URL for the remote MCP server (e.g., https://api.example.com/mcp) + URL string `json:"url" yaml:"url"` + // Headers defines HTTP headers that can be passed to the remote server for authentication + // These are used with the thv proxy command's authentication features + Headers []*Header `json:"headers,omitempty" yaml:"headers,omitempty"` + // OAuthConfig provides OAuth/OIDC configuration for authentication to the remote server + // Used with the thv proxy command's --remote-auth flags + OAuthConfig *OAuthConfig `json:"oauth_config,omitempty" yaml:"oauth_config,omitempty"` + // EnvVars defines environment variables that can be passed to configure the client + // These might be needed for client-side configuration when connecting to the remote server + EnvVars []*EnvVar `json:"env_vars,omitempty" yaml:"env_vars,omitempty"` +} + +// Metadata represents metadata about an MCP server +type Metadata struct { + // Stars represents the popularity rating or number of stars for the server + Stars int `json:"stars,omitempty" yaml:"stars,omitempty"` + // LastUpdated is the timestamp when the server was last updated, in RFC3339 format + LastUpdated string `json:"last_updated,omitempty" yaml:"last_updated,omitempty"` + // Kubernetes contains Kubernetes-specific metadata when the MCP server is deployed in a cluster. + // This field is optional and only populated when: + // - The server is served from ToolHive Registry Server + // - The server was auto-discovered from a Kubernetes deployment + // - The Kubernetes resource has the required registry annotations + Kubernetes *KubernetesMetadata `json:"kubernetes,omitempty" yaml:"kubernetes,omitempty"` +} + +// KubernetesMetadata contains Kubernetes-specific metadata for MCP servers deployed in Kubernetes clusters. +// This metadata is automatically populated by ToolHive Registry Server's auto-discovery feature, +// which publishes Kubernetes-deployed MCP servers that have the required registry annotations +// (e.g., toolhive.stacklok.com/registry-description, toolhive.stacklok.com/registry-url). +type KubernetesMetadata struct { + // Kind is the Kubernetes resource kind (e.g., MCPServer, VirtualMCPServer, MCPRemoteProxy) + Kind string `json:"kind,omitempty" yaml:"kind,omitempty"` + // Namespace is the Kubernetes namespace where the resource is deployed + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` + // Name is the Kubernetes resource name + Name string `json:"name,omitempty" yaml:"name,omitempty"` + // UID is the Kubernetes resource UID + UID string `json:"uid,omitempty" yaml:"uid,omitempty"` + // Image is the container image used by the Kubernetes workload (applicable to MCPServer) + Image string `json:"image,omitempty" yaml:"image,omitempty"` + // Transport is the transport type configured for the Kubernetes workload (applicable to MCPServer) + Transport string `json:"transport,omitempty" yaml:"transport,omitempty"` +} + +// ParsedTime returns the LastUpdated field as a time.Time +func (m *Metadata) ParsedTime() (time.Time, error) { + return time.Parse(time.RFC3339, m.LastUpdated) +} + +// UnmarshalYAML implements custom YAML unmarshaling for ImageMetadata. +// This handles flattening of embedded BaseServerMetadata fields since we can't use +// yaml:",inline" tags (they break swag v2 OpenAPI generation). +// See: https://github.com/swaggo/swag/issues/2140 +func (i *ImageMetadata) UnmarshalYAML(node *yaml.Node) error { + // Decode into embedded struct first (gets base fields from flat YAML) + if err := node.Decode(&i.BaseServerMetadata); err != nil { + return err + } + // Decode own fields using type alias to avoid infinite recursion + type plain ImageMetadata + return node.Decode((*plain)(i)) +} + +// UnmarshalYAML implements custom YAML unmarshaling for RemoteServerMetadata. +// This handles flattening of embedded BaseServerMetadata fields since we can't use +// yaml:",inline" tags (they break swag v2 OpenAPI generation). +// See: https://github.com/swaggo/swag/issues/2140 +func (r *RemoteServerMetadata) UnmarshalYAML(node *yaml.Node) error { + // Decode into embedded struct first (gets base fields from flat YAML) + if err := node.Decode(&r.BaseServerMetadata); err != nil { + return err + } + // Decode own fields using type alias to avoid infinite recursion + type plain RemoteServerMetadata + return node.Decode((*plain)(r)) +} + +// ServerMetadata is an interface that both ImageMetadata and RemoteServerMetadata implement +type ServerMetadata interface { + // GetName returns the server name + GetName() string + // GetTitle returns the optional human-readable display name + GetTitle() string + // GetDescription returns the server description + GetDescription() string + // GetTier returns the server tier + GetTier() string + // GetStatus returns the server status + GetStatus() string + // GetTransport returns the server transport + GetTransport() string + // GetTools returns the list of tools provided by the server + GetTools() []string + // GetMetadata returns the server metadata + GetMetadata() *Metadata + // GetRepositoryURL returns the repository URL + GetRepositoryURL() string + // GetTags returns the server tags + GetTags() []string + // GetOverview returns the longer Markdown-formatted description + GetOverview() string + // GetToolDefinitions returns the full MCP Tool definitions + GetToolDefinitions() []mcp.Tool + // GetCustomMetadata returns custom metadata + GetCustomMetadata() map[string]any + // IsRemote returns true if this is a remote server + IsRemote() bool + // GetEnvVars returns environment variables + GetEnvVars() []*EnvVar +} + +// Implement shared ServerMetadata accessors on BaseServerMetadata. +// These are promoted automatically to ImageMetadata and RemoteServerMetadata via embedding. + +// GetName returns the server name +func (b *BaseServerMetadata) GetName() string { + if b == nil { + return "" + } + return b.Name +} + +// GetTitle returns the optional human-readable display name +func (b *BaseServerMetadata) GetTitle() string { + if b == nil { + return "" + } + return b.Title +} + +// GetDescription returns the server description +func (b *BaseServerMetadata) GetDescription() string { + if b == nil { + return "" + } + return b.Description +} + +// GetTier returns the server tier +func (b *BaseServerMetadata) GetTier() string { + if b == nil { + return "" + } + return b.Tier +} + +// GetStatus returns the server status +func (b *BaseServerMetadata) GetStatus() string { + if b == nil { + return "" + } + return b.Status +} + +// GetTransport returns the server transport +func (b *BaseServerMetadata) GetTransport() string { + if b == nil { + return "" + } + return b.Transport +} + +// GetTools returns the list of tools provided by the server +func (b *BaseServerMetadata) GetTools() []string { + if b == nil { + return nil + } + return b.Tools +} + +// GetMetadata returns the server metadata +func (b *BaseServerMetadata) GetMetadata() *Metadata { + if b == nil { + return nil + } + return b.Metadata +} + +// GetRepositoryURL returns the repository URL +func (b *BaseServerMetadata) GetRepositoryURL() string { + if b == nil { + return "" + } + return b.RepositoryURL +} + +// GetTags returns the server tags +func (b *BaseServerMetadata) GetTags() []string { + if b == nil { + return nil + } + return b.Tags +} + +// GetOverview returns the longer Markdown-formatted description +func (b *BaseServerMetadata) GetOverview() string { + if b == nil { + return "" + } + return b.Overview +} + +// GetToolDefinitions returns the full MCP Tool definitions +func (b *BaseServerMetadata) GetToolDefinitions() []mcp.Tool { + if b == nil { + return nil + } + return b.ToolDefinitions +} + +// GetCustomMetadata returns custom metadata +func (b *BaseServerMetadata) GetCustomMetadata() map[string]any { + if b == nil { + return nil + } + return b.CustomMetadata +} + +// IsRemote returns false for container servers +func (*ImageMetadata) IsRemote() bool { + return false +} + +// GetEnvVars returns environment variables +func (i *ImageMetadata) GetEnvVars() []*EnvVar { + if i == nil { + return nil + } + return i.EnvVars +} + +// IsRemote returns true for remote servers +func (*RemoteServerMetadata) IsRemote() bool { + return true +} + +// GetEnvVars returns environment variables +func (r *RemoteServerMetadata) GetEnvVars() []*EnvVar { + if r == nil { + return nil + } + return r.EnvVars +} + +// GetRawImplementation returns the underlying RemoteServerMetadata pointer +func (r *RemoteServerMetadata) GetRawImplementation() any { + if r == nil { + return nil + } + return r +} + +// GetAllServers returns all servers (both container and remote) as a unified list +func (reg *Registry) GetAllServers() []ServerMetadata { + servers := make([]ServerMetadata, 0, len(reg.Servers)+len(reg.RemoteServers)) + + // Add container servers + for _, server := range reg.Servers { + servers = append(servers, server) + } + + // Add remote servers + for _, server := range reg.RemoteServers { + servers = append(servers, server) + } + + return servers +} + +// GetServerByName returns a server by name (either container or remote) +func (reg *Registry) GetServerByName(name string) (ServerMetadata, bool) { + // Check container servers first + if server, ok := reg.Servers[name]; ok { + return server, true + } + + // Check remote servers + if server, ok := reg.RemoteServers[name]; ok { + return server, true + } + + return nil, false +} + +// GetAllGroups returns all groups in the registry +func (reg *Registry) GetAllGroups() []*Group { + if reg == nil { + return nil + } + + return reg.Groups +} + +// GetGroupByName returns a group by name +func (reg *Registry) GetGroupByName(name string) (*Group, bool) { + if reg == nil { + return nil, false + } + + for _, group := range reg.Groups { + if group != nil && group.Name == name { + return group, true + } + } + + return nil, false +} + +// GetAllGroupServers returns all servers from a specific group (both container and remote) as a unified list +func (g *Group) GetAllGroupServers() []ServerMetadata { + if g == nil { + return nil + } + + servers := make([]ServerMetadata, 0, len(g.Servers)+len(g.RemoteServers)) + + // Add container servers from the group + for _, server := range g.Servers { + servers = append(servers, server) + } + + // Add remote servers from the group + for _, server := range g.RemoteServers { + servers = append(servers, server) + } + + return servers +} + +// SortServersByName sorts a slice of ServerMetadata by name +func SortServersByName(servers []ServerMetadata) { + slices.SortFunc(servers, func(a, b ServerMetadata) int { + if a.GetName() < b.GetName() { + return -1 + } + if a.GetName() > b.GetName() { + return 1 + } + return 0 + }) +} diff --git a/registry/types/schema_validation.go b/registry/types/schema_validation.go new file mode 100644 index 0000000..e395c58 --- /dev/null +++ b/registry/types/schema_validation.go @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "embed" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/xeipuuv/gojsonschema" +) + +//go:embed data/toolhive-legacy-registry.schema.json data/upstream-registry.schema.json data/publisher-provided.schema.json data/skill.schema.json +var embeddedSchemaFS embed.FS + +// Validate validates the Registry against the legacy ToolHive registry schema. +func (r *Registry) Validate() error { + data, err := json.Marshal(r) + if err != nil { + return fmt.Errorf("failed to serialize registry: %w", err) + } + return validateAgainstSchema(data, "data/toolhive-legacy-registry.schema.json", "registry schema validation failed") +} + +// Validate validates the UpstreamRegistry against the upstream registry schema. +// It also validates any publisher-provided extensions found in server definitions. +func (r *UpstreamRegistry) Validate() error { + data, err := json.Marshal(r) + if err != nil { + return fmt.Errorf("failed to serialize upstream registry: %w", err) + } + if err := validateAgainstSchema(data, "data/upstream-registry.schema.json", "registry schema validation failed"); err != nil { + return err + } + return validateRegistryExtensions(data) +} + +// Validate validates the ServerExtensions against the publisher-provided schema. +func (e *ServerExtensions) Validate() error { + data, err := json.Marshal(e) + if err != nil { + return fmt.Errorf("failed to serialize server extensions: %w", err) + } + const schemaFile = "data/publisher-provided.schema.json" + return validateAgainstSchema(data, schemaFile, "publisher-provided extensions schema validation failed") +} + +// Validate validates the Skill against the skill schema. +func (s *Skill) Validate() error { + data, err := json.Marshal(s) + if err != nil { + return fmt.Errorf("failed to serialize skill: %w", err) + } + return validateAgainstSchema(data, "data/skill.schema.json", "skill schema validation failed") +} + +// ValidateRegistrySchema validates raw registry JSON bytes against the legacy ToolHive registry schema. +func ValidateRegistrySchema(registryData []byte) error { + return validateAgainstSchema(registryData, "data/toolhive-legacy-registry.schema.json", "registry schema validation failed") +} + +// ValidateUpstreamRegistryBytes validates raw upstream registry JSON bytes against the upstream registry schema. +// It also validates any publisher-provided extensions found in server definitions. +func ValidateUpstreamRegistryBytes(registryData []byte) error { + const schemaFile = "data/upstream-registry.schema.json" + if err := validateAgainstSchema(registryData, schemaFile, "registry schema validation failed"); err != nil { + return err + } + return validateRegistryExtensions(registryData) +} + +// ValidatePublisherProvidedExtensionsBytes validates raw publisher-provided extensions JSON bytes. +func ValidatePublisherProvidedExtensionsBytes(extensionsData []byte) error { + const schemaFile = "data/publisher-provided.schema.json" + return validateAgainstSchema(extensionsData, schemaFile, "publisher-provided extensions schema validation failed") +} + +// ValidateSkillBytes validates raw skill JSON bytes against the skill schema. +func ValidateSkillBytes(skillData []byte) error { + return validateAgainstSchema(skillData, "data/skill.schema.json", "skill schema validation failed") +} + +// ValidateServerJSON validates a single MCP server JSON object and optionally validates +// any publisher-provided extensions found in its _meta field. +func ValidateServerJSON(serverData []byte, validateExtensions bool) error { + var server map[string]any + if err := json.Unmarshal(serverData, &server); err != nil { + return fmt.Errorf("invalid server JSON: %w", err) + } + if !validateExtensions { + return nil + } + serverName := getServerName(server, 0) + return validateServerExtensions(server, serverName) +} + +// validateAgainstSchema validates data against a named embedded schema file. +func validateAgainstSchema(data []byte, schemaFile, errPrefix string) error { + schemaData, err := embeddedSchemaFS.ReadFile(schemaFile) + if err != nil { + return fmt.Errorf("failed to read embedded schema %s: %w", schemaFile, err) + } + + result, err := gojsonschema.Validate( + gojsonschema.NewBytesLoader(schemaData), + gojsonschema.NewBytesLoader(data), + ) + if err != nil { + return fmt.Errorf("%s: %w", errPrefix, err) + } + + if result.Valid() { + return nil + } + + msgs := make([]string, 0, len(result.Errors())) + for _, desc := range result.Errors() { + msgs = append(msgs, desc.String()) + } + return formatNumberedErrors(errPrefix, msgs) +} + +// formatNumberedErrors formats a list of messages as a single error with a numbered list. +func formatNumberedErrors(prefix string, msgs []string) error { + if len(msgs) == 0 { + return nil + } + if len(msgs) == 1 { + return fmt.Errorf("%s: %s", prefix, msgs[0]) + } + var b strings.Builder + fmt.Fprintf(&b, "%s with %d errors:\n", prefix, len(msgs)) + for i, msg := range msgs { + fmt.Fprintf(&b, " %d. %s\n", i+1, msg) + } + return errors.New(strings.TrimSuffix(b.String(), "\n")) +} + +// validateRegistryExtensions parses the registry and validates publisher-provided extensions in all servers. +func validateRegistryExtensions(registryData []byte) error { + var registryMap map[string]any + if err := json.Unmarshal(registryData, ®istryMap); err != nil { + return fmt.Errorf("failed to parse registry JSON: %w", err) + } + + data, ok := registryMap["data"].(map[string]any) + if !ok { + return nil + } + + var errs []string + if servers, ok := data["servers"].([]any); ok { + errs = append(errs, validateServerList(servers, "")...) + } + if groups, ok := data["groups"].([]any); ok { + errs = append(errs, validateGroupServers(groups)...) + } + + return formatExtensionErrors(errs) +} + +func validateGroupServers(groups []any) []string { + var errs []string + for _, group := range groups { + groupMap, ok := group.(map[string]any) + if !ok { + continue + } + groupName, _ := groupMap["name"].(string) + if groupServers, ok := groupMap["servers"].([]any); ok { + errs = append(errs, validateServerList(groupServers, groupName)...) + } + } + return errs +} + +func validateServerList(servers []any, groupName string) []string { + var errs []string + for i, server := range servers { + serverMap, ok := server.(map[string]any) + if !ok { + continue + } + serverName := getServerName(serverMap, i) + if groupName != "" { + serverName = fmt.Sprintf("group[%s].%s", groupName, serverName) + } + if err := validateServerExtensions(serverMap, serverName); err != nil { + errs = append(errs, err.Error()) + } + } + return errs +} + +func formatExtensionErrors(errs []string) error { + return formatNumberedErrors("publisher-provided extensions validation failed", errs) +} + +func validateServerExtensions(server map[string]any, serverName string) error { + meta, ok := server["_meta"].(map[string]any) + if !ok { + return nil + } + publisherProvided, ok := meta[PublisherProvidedKey].(map[string]any) + if !ok { + return nil + } + extensionsData, err := json.Marshal(publisherProvided) + if err != nil { + return fmt.Errorf("server %s: failed to serialize extensions: %w", serverName, err) + } + if err := ValidatePublisherProvidedExtensionsBytes(extensionsData); err != nil { + return fmt.Errorf("server %s: %w", serverName, err) + } + return nil +} + +func getServerName(server map[string]any, index int) string { + if name, ok := server["name"].(string); ok && name != "" { + return name + } + return fmt.Sprintf("servers[%d]", index) +} diff --git a/registry/types/schema_validation_test.go b/registry/types/schema_validation_test.go new file mode 100644 index 0000000..e9361b1 --- /dev/null +++ b/registry/types/schema_validation_test.go @@ -0,0 +1,1578 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "encoding/json" + "os" + "regexp" + "testing" + + "github.com/modelcontextprotocol/registry/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRegistrySchemaValidation tests the schema validation function with various inputs +func TestRegistrySchemaValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + registryJSON string + expectError bool + errorContains string + }{ + { + name: "valid minimal registry", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": {} + }`, + expectError: false, + }, + { + name: "valid registry with server", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": { + "test-server": { + "description": "A test server for validation", + "image": "test/server:latest", + "status": "Active", + "tier": "Community", + "tools": ["test_tool"], + "transport": "stdio" + } + } + }`, + expectError: false, + }, + { + name: "missing required version field", + registryJSON: `{ + "last_updated": "2025-01-01T00:00:00Z", + "servers": {} + }`, + expectError: true, + errorContains: "version", + }, + { + name: "missing required last_updated field", + registryJSON: `{ + "version": "1.0.0", + "servers": {} + }`, + expectError: true, + errorContains: "last_updated", + }, + { + name: "missing required servers field", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z" + }`, + expectError: true, + errorContains: "servers", + }, + { + name: "invalid version format", + registryJSON: `{ + "version": "invalid-version", + "last_updated": "2025-01-01T00:00:00Z", + "servers": {} + }`, + expectError: true, + errorContains: "version", + }, + { + name: "invalid date format", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "invalid-date", + "servers": {} + }`, + expectError: true, + errorContains: "last_updated", + }, + { + name: "server missing required description", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": { + "test-server": { + "image": "test/server:latest", + "status": "Active", + "tier": "Community", + "tools": ["test_tool"], + "transport": "stdio" + } + } + }`, + expectError: true, + errorContains: "description", + }, + { + name: "server missing required image", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": { + "test-server": { + "description": "A test server for validation", + "status": "Active", + "tier": "Community", + "tools": ["test_tool"], + "transport": "stdio" + } + } + }`, + expectError: true, + errorContains: "image", + }, + { + name: "server with invalid status", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": { + "test-server": { + "description": "A test server for validation", + "image": "test/server:latest", + "status": "InvalidStatus", + "tier": "Community", + "tools": ["test_tool"], + "transport": "stdio" + } + } + }`, + expectError: true, + errorContains: "status", + }, + { + name: "server with invalid tier", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": { + "test-server": { + "description": "A test server for validation", + "image": "test/server:latest", + "status": "Active", + "tier": "InvalidTier", + "tools": ["test_tool"], + "transport": "stdio" + } + } + }`, + expectError: true, + errorContains: "tier", + }, + { + name: "server with invalid transport", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": { + "test-server": { + "description": "A test server for validation", + "image": "test/server:latest", + "status": "Active", + "tier": "Community", + "tools": ["test_tool"], + "transport": "invalid-transport" + } + } + }`, + expectError: true, + errorContains: "transport", + }, + { + name: "server with empty tools array", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": { + "test-server": { + "description": "A test server for validation", + "image": "test/server:latest", + "status": "Active", + "tier": "Community", + "tools": [], + "transport": "stdio" + } + } + }`, + expectError: true, + errorContains: "tools", + }, + { + name: "server with description too short", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": { + "test-server": { + "description": "Short", + "image": "test/server:latest", + "status": "Active", + "tier": "Community", + "tools": ["test_tool"], + "transport": "stdio" + } + } + }`, + expectError: true, + errorContains: "description", + }, + { + name: "invalid server name pattern", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": { + "Invalid_Server_Name": { + "description": "A test server for validation", + "image": "test/server:latest", + "status": "Active", + "tier": "Community", + "tools": ["test_tool"], + "transport": "stdio" + } + } + }`, + expectError: true, + errorContains: "Additional property", + }, + { + name: "valid remote server", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": {}, + "remote_servers": { + "test-remote": { + "url": "https://api.example.com/mcp", + "description": "A test remote server for validation", + "status": "Active", + "tier": "Community", + "tools": ["remote_tool"], + "transport": "sse" + } + } + }`, + expectError: false, + }, + { + name: "remote server with invalid transport (stdio not allowed)", + registryJSON: `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": {}, + "remote_servers": { + "test-remote": { + "url": "https://api.example.com/mcp", + "description": "A test remote server for validation", + "status": "Active", + "tier": "Community", + "tools": ["remote_tool"], + "transport": "stdio" + } + } + }`, + expectError: true, + errorContains: "transport", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateRegistrySchema([]byte(tt.registryJSON)) + + if tt.expectError { + require.Error(t, err, "Expected validation to fail for test case: %s", tt.name) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains, "Error should contain expected text") + } + } else { + require.NoError(t, err, "Expected validation to pass for test case: %s", tt.name) + } + }) + } +} + +// TestValidateRegistrySchemaWithInvalidJSON tests that the function handles invalid JSON gracefully +func TestValidateRegistrySchemaWithInvalidJSON(t *testing.T) { + t.Parallel() + + invalidJSON := `{ + "version": "1.0.0", + "last_updated": "2025-01-01T00:00:00Z", + "servers": { + "test-server": { + "description": "A test server" + // Missing comma - invalid JSON + "image": "test/server:latest" + } + } + }` + + err := ValidateRegistrySchema([]byte(invalidJSON)) + require.Error(t, err) + // gojsonschema returns validation error for invalid JSON + assert.Contains(t, err.Error(), "invalid character") +} + +// TestMultipleValidationErrors tests that multiple validation errors are reported together +func TestMultipleValidationErrors(t *testing.T) { + t.Parallel() + + // Registry with multiple validation errors + invalidRegistryJSON := `{ + "servers": { + "test-server": { + "description": "Short", + "status": "InvalidStatus", + "tier": "InvalidTier", + "tools": [], + "transport": "invalid-transport" + } + } + }` + + err := ValidateRegistrySchema([]byte(invalidRegistryJSON)) + require.Error(t, err, "Expected validation to fail with multiple errors") + + errorMsg := err.Error() + + // Should contain multiple errors + assert.Contains(t, errorMsg, "validation failed with", "Should indicate multiple errors") + + // Should contain specific error details + assert.Contains(t, errorMsg, "version", "Should mention missing version") + assert.Contains(t, errorMsg, "last_updated", "Should mention missing last_updated") + assert.Contains(t, errorMsg, "description", "Should mention description length issue") + assert.Contains(t, errorMsg, "status", "Should mention invalid status") + assert.Contains(t, errorMsg, "tools", "Should mention empty tools array") + + // Verify it's formatted as a numbered list + assert.Contains(t, errorMsg, "1.", "Should have numbered error list") + assert.Contains(t, errorMsg, "2.", "Should have multiple numbered errors") + + t.Logf("Multi-error output:\n%s", errorMsg) +} + +// TestValidateUpstreamRegistry tests the ValidateUpstreamRegistry function +func TestValidateUpstreamRegistryBytes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + wantErr bool + errorContains string + }{ + { + name: "valid registry with all fields", + data: `{ + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/upstream-registry.schema.json", + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [], + "groups": [] + } + }`, + wantErr: false, + }, + { + name: "valid registry without groups (optional)", + data: `{ + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/upstream-registry.schema.json", + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [] + } + }`, + wantErr: false, + }, + { + name: "valid registry with group", + data: `{ + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/upstream-registry.schema.json", + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [], + "groups": [ + { + "name": "test-group", + "description": "Test group", + "servers": [] + } + ] + } + }`, + wantErr: false, + }, + { + name: "missing meta", + data: `{ + "version": "1.0.0", + "data": { + "servers": [] + } + }`, + wantErr: true, + errorContains: "meta", + }, + { + name: "missing data", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + } + }`, + wantErr: true, + errorContains: "data", + }, + { + name: "missing servers in data", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": {} + }`, + wantErr: true, + errorContains: "servers", + }, + { + name: "missing last_updated in meta", + data: `{ + "version": "1.0.0", + "meta": {}, + "data": { + "servers": [] + } + }`, + wantErr: true, + errorContains: "last_updated", + }, + { + name: "invalid version format", + data: `{ + "version": "invalid", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [] + } + }`, + wantErr: true, + errorContains: "version", + }, + { + name: "invalid date format", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "not-a-date" + }, + "data": { + "servers": [] + } + }`, + wantErr: true, + errorContains: "date-time", + }, + { + name: "missing required group fields", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [], + "groups": [ + { + "name": "incomplete-group" + } + ] + } + }`, + wantErr: true, + errorContains: "description", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateUpstreamRegistryBytes([]byte(tt.data)) + if tt.wantErr { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestValidateUpstreamRegistry_RealWorld tests validation with realistic registry data +func TestValidateUpstreamRegistry_RealWorld(t *testing.T) { + t.Parallel() + + // Simulate a realistic upstream registry + realWorldRegistry := `{ + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/upstream-registry.schema.json", + "version": "1.0.0", + "meta": { + "last_updated": "2024-11-25T10:30:00Z" + }, + "data": { + "servers": [ + { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.stacklok/test-server", + "description": "A test MCP server", + "version": "1.0.0", + "title": "Test Server" + } + ], + "groups": [] + } + }` + + err := ValidateUpstreamRegistryBytes([]byte(realWorldRegistry)) + assert.NoError(t, err, "Real-world registry example should validate successfully") +} + +// walkJSONObjects walks through nested JSON objects following the provided path. +// Returns the final object and true if successful, or nil and false if any path segment fails. +func walkJSONObjects(root map[string]any, paths ...string) (map[string]any, bool) { + current := root + for _, path := range paths { + next, ok := current[path].(map[string]any) + if !ok { + return nil, false + } + current = next + } + return current, true +} + +// TestValidatePublisherProvidedExtensions tests the ValidatePublisherProvidedExtensions function +func TestValidatePublisherProvidedExtensionsBytes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + wantErr bool + errorContains string + }{ + { + name: "valid image extensions", + data: `{ + "io.github.stacklok": { + "ghcr.io/example/server:v1.0.0": { + "status": "active", + "tier": "Official", + "tools": ["tool1", "tool2"], + "tags": ["api", "test"], + "metadata": { + "stars": 100, + "last_updated": "2025-01-15T10:30:00Z" + }, + "permissions": { + "network": { + "outbound": { + "allow_host": [".example.com"], + "allow_port": [443] + } + } + }, + "args": ["--verbose"], + "docker_tags": ["v1.0.0", "latest"], + "proxy_port": 8080, + "custom_metadata": { + "maintainer": "Test User" + } + } + } + }`, + wantErr: false, + }, + { + name: "valid extensions with tool_definitions", + data: `{ + "io.github.stacklok": { + "ghcr.io/example/server:v1.0.0": { + "status": "active", + "tools": ["add", "echo"], + "tool_definitions": [ + { + "name": "add", + "description": "Adds two numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"} + }, + "required": ["a", "b"] + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "echo", + "description": "Echoes back the input", + "inputSchema": { + "type": "object", + "properties": { + "message": {"type": "string"} + }, + "required": ["message"] + } + } + ] + } + } + }`, + wantErr: false, + }, + { + name: "valid remote extensions", + data: `{ + "io.github.stacklok": { + "https://api.example.com/mcp": { + "status": "active", + "tier": "Community", + "tools": ["remote_tool"], + "tags": ["remote", "api"], + "metadata": { + "stars": 50, + "last_updated": "2025-01-15T10:30:00Z" + }, + "oauth_config": { + "issuer": "https://auth.example.com", + "client_id": "test-client", + "scopes": ["openid", "profile"], + "use_pkce": true, + "oauth_params": { + "prompt": "consent" + }, + "callback_port": 8000, + "resource": "https://api.example.com" + }, + "env_vars": [ + { + "name": "API_KEY", + "description": "API key for authentication", + "required": true, + "secret": true + } + ], + "custom_metadata": { + "provider": "Example Corp" + } + } + } + }`, + wantErr: false, + }, + { + name: "valid minimal extensions (status only)", + data: `{ + "io.github.stacklok": { + "ghcr.io/minimal/server:latest": { + "status": "active" + } + } + }`, + wantErr: false, + }, + { + name: "missing required status field", + data: `{ + "io.github.stacklok": { + "ghcr.io/example/server:v1.0.0": { + "tier": "Official" + } + } + }`, + wantErr: true, + errorContains: "status", + }, + { + name: "invalid status value", + data: `{ + "io.github.stacklok": { + "ghcr.io/example/server:v1.0.0": { + "status": "invalid-status" + } + } + }`, + wantErr: true, + errorContains: "status", + }, + { + name: "invalid tier value", + data: `{ + "io.github.stacklok": { + "ghcr.io/example/server:v1.0.0": { + "status": "active", + "tier": "InvalidTier" + } + } + }`, + wantErr: true, + errorContains: "tier", + }, + { + name: "invalid proxy_port (too high)", + data: `{ + "io.github.stacklok": { + "ghcr.io/example/server:v1.0.0": { + "status": "active", + "proxy_port": 70000 + } + } + }`, + wantErr: true, + errorContains: "proxy_port", + }, + { + name: "invalid metadata stars (negative)", + data: `{ + "io.github.stacklok": { + "ghcr.io/example/server:v1.0.0": { + "status": "active", + "metadata": { + "stars": -1 + } + } + } + }`, + wantErr: true, + errorContains: "stars", + }, + { + name: "invalid oauth_config callback_port", + data: `{ + "io.github.stacklok": { + "https://api.example.com/mcp": { + "status": "active", + "oauth_config": { + "callback_port": 0 + } + } + } + }`, + wantErr: true, + errorContains: "callback_port", + }, + { + name: "valid provenance structure", + data: `{ + "io.github.stacklok": { + "ghcr.io/example/server:v1.0.0": { + "status": "active", + "provenance": { + "sigstore_url": "tuf-repo-cdn.sigstore.dev", + "repository_uri": "https://github.com/example/server", + "signer_identity": "/.github/workflows/release.yml", + "runner_environment": "github-hosted", + "cert_issuer": "https://token.actions.githubusercontent.com" + } + } + } + }`, + wantErr: false, + }, + { + name: "empty publisher namespace is valid", + data: `{ + "io.github.stacklok": {} + }`, + wantErr: false, + }, + { + name: "allows other publisher namespaces", + data: `{ + "io.github.stacklok": { + "ghcr.io/example/server:v1.0.0": { + "status": "active" + } + }, + "io.example.other": { + "arbitrary": "data" + } + }`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidatePublisherProvidedExtensionsBytes([]byte(tt.data)) + if tt.wantErr { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestValidatePublisherProvidedExtensions_ConverterOutput tests that actual converter output validates +func TestValidatePublisherProvidedExtensions_ConverterOutput(t *testing.T) { + t.Parallel() + + // This is a realistic output from the createImageExtensions converter + converterOutput := `{ + "io.github.stacklok": { + "ghcr.io/github/github-mcp-server:v0.19.1": { + "status": "Active", + "tier": "Official", + "tools": [ + "add_comment_to_pending_review", + "create_issue", + "get_file_contents" + ], + "tags": ["api", "github", "repository"], + "metadata": { + "stars": 23700, + "last_updated": "2025-10-18T02:26:51Z" + }, + "permissions": { + "network": { + "outbound": { + "allow_host": [".github.com", ".githubusercontent.com"], + "allow_port": [443] + } + } + }, + "provenance": { + "cert_issuer": "https://token.actions.githubusercontent.com", + "repository_uri": "https://github.com/github/github-mcp-server", + "runner_environment": "github-hosted", + "signer_identity": "/.github/workflows/docker-publish.yml", + "sigstore_url": "tuf-repo-cdn.sigstore.dev" + }, + "docker_tags": ["v0.19.1", "v0.19.0", "latest"], + "proxy_port": 8080, + "custom_metadata": { + "maintainer": "GitHub", + "license": "MIT" + } + } + } + }` + + err := ValidatePublisherProvidedExtensionsBytes([]byte(converterOutput)) + assert.NoError(t, err, "Converter output should validate against the schema") +} + +// TestValidatePublisherProvidedExtensions_RemoteConverterOutput tests remote server converter output +func TestValidatePublisherProvidedExtensions_RemoteConverterOutput(t *testing.T) { + t.Parallel() + + // This is a realistic output from the createRemoteExtensions converter + converterOutput := `{ + "io.github.stacklok": { + "https://api.example.com/mcp": { + "status": "active", + "tier": "Community", + "tools": ["get_data", "send_notification", "query_api"], + "tags": ["remote", "sse", "api"], + "metadata": { + "stars": 150, + "last_updated": "2025-10-20T10:00:00Z" + }, + "oauth_config": { + "issuer": "https://auth.example.com", + "client_id": "example-client", + "scopes": ["openid", "profile"] + }, + "env_vars": [ + { + "name": "API_ENDPOINT", + "description": "Base URL for API calls", + "required": false, + "default": "https://api.example.com" + }, + { + "name": "CLIENT_SECRET", + "description": "Client secret for OAuth", + "required": true, + "secret": true + } + ], + "custom_metadata": { + "provider": "Example Corp", + "api_version": "v2" + } + } + } + }` + + err := ValidatePublisherProvidedExtensionsBytes([]byte(converterOutput)) + assert.NoError(t, err, "Remote converter output should validate against the schema") +} + +// TestValidateUpstreamRegistry_WithExtensions tests that ValidateUpstreamRegistry also validates extensions +func TestValidateUpstreamRegistry_WithExtensions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + wantErr bool + errorContains string + }{ + { + name: "valid registry with valid extensions", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [ + { + "name": "io.github.stacklok/test-server", + "description": "A test server", + "version": "1.0.0", + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/test/server:v1.0.0": { + "status": "active", + "tier": "Official", + "tools": ["tool1"] + } + } + } + } + } + ] + } + }`, + wantErr: false, + }, + { + name: "valid registry without extensions", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [ + { + "name": "io.github.stacklok/test-server", + "description": "A test server", + "version": "1.0.0" + } + ] + } + }`, + wantErr: false, + }, + { + name: "valid registry with _meta but no publisher-provided", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [ + { + "name": "io.github.stacklok/test-server", + "description": "A test server", + "version": "1.0.0", + "_meta": { + "some-other-key": {} + } + } + ] + } + }`, + wantErr: false, + }, + { + name: "invalid extensions - missing required status", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [ + { + "name": "io.github.stacklok/test-server", + "description": "A test server", + "version": "1.0.0", + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/test/server:v1.0.0": { + "tier": "Official" + } + } + } + } + } + ] + } + }`, + wantErr: true, + errorContains: "status", + }, + { + name: "invalid extensions - invalid tier value", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [ + { + "name": "io.github.stacklok/test-server", + "description": "A test server", + "version": "1.0.0", + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/test/server:v1.0.0": { + "status": "active", + "tier": "InvalidTier" + } + } + } + } + } + ] + } + }`, + wantErr: true, + errorContains: "tier", + }, + { + name: "valid registry with extensions in groups", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [], + "groups": [ + { + "name": "test-group", + "description": "A test group", + "servers": [ + { + "name": "io.github.stacklok/grouped-server", + "description": "A grouped server", + "version": "1.0.0", + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/test/grouped:v1.0.0": { + "status": "active" + } + } + } + } + } + ] + } + ] + } + }`, + wantErr: false, + }, + { + name: "invalid extensions in group server", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [], + "groups": [ + { + "name": "test-group", + "description": "A test group", + "servers": [ + { + "name": "io.github.stacklok/grouped-server", + "description": "A grouped server", + "version": "1.0.0", + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/test/grouped:v1.0.0": { + "status": "invalid-status" + } + } + } + } + } + ] + } + ] + } + }`, + wantErr: true, + errorContains: "status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateUpstreamRegistryBytes([]byte(tt.data)) + if tt.wantErr { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestValidateServerJSON tests the ValidateServerJSON function +func TestValidateServerJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + validateExtensions bool + wantErr bool + errorContains string + }{ + { + name: "valid server without extension validation", + data: `{ + "name": "test-server", + "description": "A test server", + "version": "1.0.0" + }`, + validateExtensions: false, + wantErr: false, + }, + { + name: "valid server with valid extensions", + data: `{ + "name": "test-server", + "description": "A test server", + "version": "1.0.0", + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/test/server:v1.0.0": { + "status": "active", + "tier": "Official" + } + } + } + } + }`, + validateExtensions: true, + wantErr: false, + }, + { + name: "server with invalid extensions - validate enabled", + data: `{ + "name": "test-server", + "description": "A test server", + "version": "1.0.0", + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/test/server:v1.0.0": { + "tier": "Official" + } + } + } + } + }`, + validateExtensions: true, + wantErr: true, + errorContains: "status", + }, + { + name: "server with invalid extensions - validate disabled", + data: `{ + "name": "test-server", + "description": "A test server", + "version": "1.0.0", + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/test/server:v1.0.0": { + "tier": "InvalidTier" + } + } + } + } + }`, + validateExtensions: false, + wantErr: false, + }, + { + name: "invalid JSON", + data: `{invalid json`, + validateExtensions: false, + wantErr: true, + errorContains: "invalid", + }, + { + name: "server without _meta - validate enabled", + data: `{ + "name": "test-server", + "description": "A test server" + }`, + validateExtensions: true, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateServerJSON([]byte(tt.data), tt.validateExtensions) + if tt.wantErr { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestConverterFixtures_PublisherProvidedSchemaValidation validates that converter output fixture files +// containing _meta extensions conform to the publisher-provided schema. +// This ensures that the converter-produced extensions remain valid as the schema evolves. +// +// Note: Only "expected_*" files are validated since they represent what ToolHive converters produce. +// The "input_*" files represent external registry data which may contain additional fields +// that ToolHive tolerates but doesn't produce. +func TestConverterFixtures_PublisherProvidedSchemaValidation(t *testing.T) { + t.Parallel() + + // Expected output fixtures that contain _meta with publisher-provided extensions + // These represent what ToolHive converters produce and should conform to the schema + // Paths are relative to pkg/registry/ (the current package directory) + fixturesWithMeta := []string{ + "../converters/testdata/image_to_server/expected_github.json", + "../converters/testdata/remote_to_server/expected_example.json", + } + + for _, fixturePath := range fixturesWithMeta { + t.Run(fixturePath, func(t *testing.T) { + t.Parallel() + + // Read the fixture file from disk + data, err := os.ReadFile(fixturePath) + require.NoError(t, err, "Failed to read fixture: %s", fixturePath) + + // Parse the JSON to extract the _meta field + var serverJSON map[string]any + require.NoError(t, json.Unmarshal(data, &serverJSON), "Failed to parse fixture JSON") + + // Extract _meta["io.modelcontextprotocol.registry/publisher-provided"] + meta, ok := serverJSON["_meta"].(map[string]any) + require.True(t, ok, "Fixture should have _meta field") + + publisherProvided, ok := meta["io.modelcontextprotocol.registry/publisher-provided"].(map[string]any) + require.True(t, ok, "Fixture should have publisher-provided extensions in _meta") + + // Serialize just the publisher-provided extensions for validation + extensionsData, err := json.Marshal(publisherProvided) + require.NoError(t, err, "Failed to marshal publisher-provided extensions") + + // Validate against the schema + err = ValidatePublisherProvidedExtensionsBytes(extensionsData) + assert.NoError(t, err, "Fixture %s publisher-provided extensions should validate against schema", fixturePath) + }) + } +} + +// TestValidateSkillSchema tests the ValidateSkillSchema function +func TestValidateSkillBytes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + wantErr bool + errorContains string + }{ + { + name: "valid minimal skill", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "description": "Extract text and tables from PDF files", + "version": "1.0.0" + }`, + wantErr: false, + }, + { + name: "valid skill with all fields", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "description": "Extract text and tables from PDF files", + "version": "1.0.0", + "status": "active", + "title": "PDF Processor", + "license": "Apache-2.0", + "compatibility": "Requires Docker runtime", + "allowedTools": ["read-file", "write-file"], + "repository": { + "url": "https://github.com/stacklok/skills/pdf-processor", + "type": "git" + }, + "icons": [ + { + "src": "https://example.com/icon.png", + "size": "64x64", + "type": "image/png", + "label": "PDF icon" + } + ], + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/stacklok/skills/pdf-processor:1.0.0", + "digest": "sha256:abc123", + "mediaType": "application/vnd.stacklok.skillet.skill.v1" + } + ], + "metadata": { + "author": "Stacklok" + }, + "_meta": { + "io.github.stacklok": {} + } + }`, + wantErr: false, + }, + { + name: "valid skill with git package", + data: `{ + "namespace": "io.github.user", + "name": "my-skill", + "description": "A custom skill from a git repository", + "version": "abc123def", + "packages": [ + { + "registryType": "git", + "url": "https://github.com/user/my-skill", + "ref": "main", + "commit": "abc123def456", + "subfolder": "skills/my-skill" + } + ] + }`, + wantErr: false, + }, + { + name: "missing required namespace", + data: `{ + "name": "pdf-processor", + "description": "Extract text and tables", + "version": "1.0.0" + }`, + wantErr: true, + errorContains: "namespace", + }, + { + name: "missing required name", + data: `{ + "namespace": "io.github.stacklok", + "description": "Extract text and tables", + "version": "1.0.0" + }`, + wantErr: true, + errorContains: "name", + }, + { + name: "missing required description", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "version": "1.0.0" + }`, + wantErr: true, + errorContains: "description", + }, + { + name: "missing required version", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "description": "Extract text and tables" + }`, + wantErr: true, + errorContains: "version", + }, + { + name: "invalid status value", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "description": "Extract text and tables", + "version": "1.0.0", + "status": "invalid-status" + }`, + wantErr: true, + errorContains: "status", + }, + { + name: "invalid package registryType", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "description": "Extract text and tables", + "version": "1.0.0", + "packages": [ + { + "registryType": "invalid" + } + ] + }`, + wantErr: true, + errorContains: "registryType", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateSkillBytes([]byte(tt.data)) + if tt.wantErr { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestUpstreamRegistrySchemaVersionSync ensures that the schema reference in +// upstream-registry.schema.json matches the schema version from the Go package +// (model.CurrentSchemaVersion). This prevents schema drift when upgrading the +// modelcontextprotocol/registry package. +func TestUpstreamRegistrySchemaVersionSync(t *testing.T) { + t.Parallel() + + // Read the upstream registry schema file + schemaPath := "data/upstream-registry.schema.json" + schemaData, err := embeddedSchemaFS.ReadFile(schemaPath) + if err != nil { + t.Fatalf("Failed to read embedded schema file: %v", err) + } + + // Parse the schema JSON + var schema map[string]interface{} + if err := json.Unmarshal(schemaData, &schema); err != nil { + t.Fatalf("Failed to parse schema JSON: %v", err) + } + + // Navigate to the $ref field in data.properties.servers.items + items, ok := walkJSONObjects(schema, "properties", "data", "properties", "servers", "items") + if !ok { + t.Fatal("Failed to navigate to data.properties.servers.items in schema") + } + + refURL, ok := items["$ref"].(string) + if !ok { + t.Fatal("Failed to get $ref URL from items") + } + + // Extract the date from the URL + // Expected format: https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json + re := regexp.MustCompile(`/schemas/([0-9]{4}-[0-9]{2}-[0-9]{2})/`) + matches := re.FindStringSubmatch(refURL) + if len(matches) != 2 { + t.Fatalf("Failed to extract date from schema URL: %s", refURL) + } + schemaDate := matches[1] + + // Compare with the Go package constant + expectedDate := model.CurrentSchemaVersion + if schemaDate != expectedDate { + t.Errorf("Schema version mismatch!\n"+ + " Schema file (%s): %s\n"+ + " Go package (model.CurrentSchemaVersion): %s\n\n"+ + "To fix: Update pkg/registry/data/upstream-registry.schema.json to use date %s:\n"+ + " In data.properties.servers.items.$ref:\n"+ + " \"$ref\": \"https://static.modelcontextprotocol.io/schemas/%s/server.schema.json\"", + schemaPath, schemaDate, expectedDate, expectedDate, expectedDate) + } + + // Also check groups schema if present + groupServerItems, ok := walkJSONObjects(schema, "properties", "data", "properties", "groups", "items", "properties", "servers", "items") + if ok { + groupRefURL, ok := groupServerItems["$ref"].(string) + if ok { + groupMatches := re.FindStringSubmatch(groupRefURL) + if len(groupMatches) == 2 { + groupSchemaDate := groupMatches[1] + if groupSchemaDate != expectedDate { + t.Errorf("Groups schema version mismatch!\n"+ + " Groups $ref date: %s\n"+ + " Expected: %s\n\n"+ + "To fix: Update data.properties.groups.items.properties.servers.items.$ref", + groupSchemaDate, expectedDate) + } + } + } + } +} diff --git a/registry/types/skills_types.go b/registry/types/skills_types.go new file mode 100644 index 0000000..bf18994 --- /dev/null +++ b/registry/types/skills_types.go @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +// SkillPackage represents a distribution package (OCI or Git) in request/response payloads. +type SkillPackage struct { + // RegistryType is the type of registry the package is from. + // Can be "oci" or "git". + RegistryType string `json:"registryType"` // "oci" or "git" + // Identifier is the OCI identifier of the package. + Identifier string `json:"identifier,omitempty"` + // Digest is the digest of the package. + Digest string `json:"digest,omitempty"` + // MediaType is the media type of the package. + MediaType string `json:"mediaType,omitempty"` + // URL is the URL of the package. + URL string `json:"url,omitempty"` + // Ref is the reference of the package. + Ref string `json:"ref,omitempty"` + // Commit is the commit of the package. + Commit string `json:"commit,omitempty"` + // Subfolder is the subfolder of the package. + Subfolder string `json:"subfolder,omitempty"` +} + +// SkillIcon represents a display icon for a skill. +type SkillIcon struct { + // Src is the source of the icon. + Src string `json:"src"` + // Size is the size of the icon. + Size string `json:"size,omitempty"` + // Type is the type of the icon. + Type string `json:"type,omitempty"` + // Label is the label of the icon. + Label string `json:"label,omitempty"` +} + +// SkillRepository represents source repository metadata. +type SkillRepository struct { + // URL is the URL of the repository. + URL string `json:"url,omitempty"` + // Type is the type of the repository. + Type string `json:"type,omitempty"` +} + +// Skill is a single skill in list and get responses or publish requests. +type Skill struct { + // Namespace is the namespace of the skill. + // The format is reverse-DNS, e.g. "io.github.user". + Namespace string `json:"namespace"` + // Name is the name of the skill. + // The format is that of identifiers, e.g. "my-skill". + Name string `json:"name"` + // Description is the description of the skill. + Description string `json:"description"` + // Version is the version of the skill. + // Any non-empty string is valid, but ideally it should be either a + // semantic version or a commit hash. + Version string `json:"version"` + // Status is the status of the skill. + // Can be one of "active", "deprecated", or "archived". + Status string `json:"status,omitempty"` // active, deprecated, archived + // Title is the title of the skill. + // This is for human consumption, not an identifier. + Title string `json:"title,omitempty"` + // License is the SPDX license identifier of the skill. + License string `json:"license,omitempty"` + // Compatibility is the environment requirements of the skill. + Compatibility string `json:"compatibility,omitempty"` + // AllowedTools is the list of tools that the skill is compatible with. + // This is experimental. + AllowedTools []string `json:"allowedTools,omitempty"` + // Repository is the source repository of the skill. + Repository *SkillRepository `json:"repository,omitempty"` + // Icons is the list of icons for the skill. + Icons []SkillIcon `json:"icons,omitempty"` + // Packages is the list of packages for the skill. + Packages []SkillPackage `json:"packages,omitempty"` + // Metadata is the official metadata of the skill as reported in the + // SKILL.md file. + Metadata map[string]any `json:"metadata,omitempty"` + // Meta is an opaque payload with extended meta data details of the skill. + Meta map[string]any `json:"_meta,omitempty"` +} diff --git a/registry/types/upstream_registry.go b/registry/types/upstream_registry.go new file mode 100644 index 0000000..89e7769 --- /dev/null +++ b/registry/types/upstream_registry.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + upstreamv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" +) + +// UpstreamRegistrySchemaURL is the canonical URL for the upstream registry JSON schema. +const UpstreamRegistrySchemaURL = "https://raw.githubusercontent.com/stacklok/toolhive/main/" + + "pkg/registry/data/upstream-registry.schema.json" + +// UpstreamRegistry is the unified registry format that stores servers in upstream +// ServerJSON format with proper meta/data separation and groups support. +type UpstreamRegistry struct { + // Schema is the JSON schema URL for validation + Schema string `json:"$schema" yaml:"$schema"` + + // Version is the schema version (e.g., "1.0.0") + Version string `json:"version" yaml:"version"` + + // Meta contains registry metadata + Meta UpstreamMeta `json:"meta" yaml:"meta"` + + // Data contains the actual registry content + Data UpstreamData `json:"data" yaml:"data"` +} + +// UpstreamMeta contains metadata about the registry +type UpstreamMeta struct { + // LastUpdated is the timestamp when registry was last updated in RFC3339 format + LastUpdated string `json:"last_updated" yaml:"last_updated"` +} + +// UpstreamData contains the actual registry content (servers, groups, and skills) +type UpstreamData struct { + // Servers contains the server definitions in upstream MCP format + Servers []upstreamv0.ServerJSON `json:"servers" yaml:"servers"` + + // Groups contains grouped collections of servers (optional, for future use) + Groups []UpstreamGroup `json:"groups,omitempty" yaml:"groups,omitempty"` + + // Skills contains the skill definitions + Skills []Skill `json:"skills,omitempty" yaml:"skills,omitempty"` +} + +// UpstreamGroup represents a named collection of related MCP servers +type UpstreamGroup struct { + // Name is the unique identifier for the group + Name string `json:"name" yaml:"name"` + + // Description explains the purpose of this group + Description string `json:"description" yaml:"description"` + + // Servers contains the server definitions in this group + Servers []upstreamv0.ServerJSON `json:"servers" yaml:"servers"` +} diff --git a/registry/types/upstream_registry_test.go b/registry/types/upstream_registry_test.go new file mode 100644 index 0000000..a2b892a --- /dev/null +++ b/registry/types/upstream_registry_test.go @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "encoding/json" + "testing" + "time" + + upstreamv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestUpstreamRegistry_JSONSerialization(t *testing.T) { + t.Parallel() + registry := &UpstreamRegistry{ + Schema: "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/upstream-registry.schema.json", + Version: "1.0.0", + Meta: UpstreamMeta{ + LastUpdated: time.Now().Format(time.RFC3339), + }, + Data: UpstreamData{ + Servers: []upstreamv0.ServerJSON{}, + Groups: []UpstreamGroup{}, + }, + } + + // Test JSON marshaling + jsonData, err := json.MarshalIndent(registry, "", " ") + require.NoError(t, err) + assert.Contains(t, string(jsonData), `"$schema"`) + assert.Contains(t, string(jsonData), `"meta"`) + assert.Contains(t, string(jsonData), `"data"`) + + // Test JSON unmarshaling + var decoded UpstreamRegistry + err = json.Unmarshal(jsonData, &decoded) + require.NoError(t, err) + assert.Equal(t, registry.Version, decoded.Version) + assert.Equal(t, registry.Schema, decoded.Schema) + assert.Equal(t, registry.Meta.LastUpdated, decoded.Meta.LastUpdated) +} + +func TestUpstreamRegistry_YAMLSerialization(t *testing.T) { + t.Parallel() + registry := &UpstreamRegistry{ + Schema: "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/upstream-registry.schema.json", + Version: "1.0.0", + Meta: UpstreamMeta{ + LastUpdated: "2024-01-15T10:30:00Z", + }, + Data: UpstreamData{ + Servers: []upstreamv0.ServerJSON{}, + Groups: []UpstreamGroup{}, + }, + } + + // Test YAML marshaling + yamlData, err := yaml.Marshal(registry) + require.NoError(t, err) + assert.Contains(t, string(yamlData), "meta:") + assert.Contains(t, string(yamlData), "data:") + + // Test YAML unmarshaling + var decoded UpstreamRegistry + err = yaml.Unmarshal(yamlData, &decoded) + require.NoError(t, err) + assert.Equal(t, registry.Version, decoded.Version) + assert.Equal(t, registry.Meta.LastUpdated, decoded.Meta.LastUpdated) +} + +func TestUpstreamRegistry_WithGroups(t *testing.T) { + t.Parallel() + registry := &UpstreamRegistry{ + Schema: "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/upstream-registry.schema.json", + Version: "1.0.0", + Meta: UpstreamMeta{ + LastUpdated: time.Now().Format(time.RFC3339), + }, + Data: UpstreamData{ + Servers: []upstreamv0.ServerJSON{}, + Groups: []UpstreamGroup{ + { + Name: "test-group", + Description: "Test group for testing", + Servers: []upstreamv0.ServerJSON{}, + }, + }, + }, + } + + jsonData, err := json.Marshal(registry) + require.NoError(t, err) + + var decoded UpstreamRegistry + err = json.Unmarshal(jsonData, &decoded) + require.NoError(t, err) + assert.Len(t, decoded.Data.Groups, 1) + assert.Equal(t, "test-group", decoded.Data.Groups[0].Name) +} + +func TestUpstreamRegistry_SchemaField(t *testing.T) { + t.Parallel() + + registry := &UpstreamRegistry{ + Schema: "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/upstream-registry.schema.json", + Version: "1.0.0", + Meta: UpstreamMeta{ + LastUpdated: time.Now().Format(time.RFC3339), + }, + Data: UpstreamData{ + Servers: []upstreamv0.ServerJSON{}, + Groups: []UpstreamGroup{}, + }, + } + + // Verify schema field is correctly serialized with "$schema" key + jsonData, err := json.Marshal(registry) + require.NoError(t, err) + assert.Contains(t, string(jsonData), `"$schema":"https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/upstream-registry.schema.json"`) + + // Verify schema field can be deserialized + var decoded UpstreamRegistry + err = json.Unmarshal(jsonData, &decoded) + require.NoError(t, err) + assert.Equal(t, registry.Schema, decoded.Schema) +} + +func TestRegistryMeta_TimeFormat(t *testing.T) { + t.Parallel() + + // Test RFC3339 timestamp format + timestamp := "2024-01-15T10:30:00Z" + meta := UpstreamMeta{ + LastUpdated: timestamp, + } + + jsonData, err := json.Marshal(meta) + require.NoError(t, err) + + var decoded UpstreamMeta + err = json.Unmarshal(jsonData, &decoded) + require.NoError(t, err) + assert.Equal(t, timestamp, decoded.LastUpdated) + + // Verify the timestamp is valid RFC3339 + parsedTime, err := time.Parse(time.RFC3339, decoded.LastUpdated) + require.NoError(t, err) + assert.NotZero(t, parsedTime) +} + +func TestRegistryData_EmptyOptionalFields(t *testing.T) { + t.Parallel() + + // Test that groups and skills can be omitted (omitempty) + data := UpstreamData{ + Servers: []upstreamv0.ServerJSON{}, + } + + jsonData, err := json.Marshal(data) + require.NoError(t, err) + + // Groups and skills should not appear in JSON when nil (omitempty behavior) + assert.NotContains(t, string(jsonData), "groups") + assert.NotContains(t, string(jsonData), "skills") + + // Test with empty slices - also omitted due to omitempty + data.Groups = []UpstreamGroup{} + data.Skills = []Skill{} + jsonData, err = json.Marshal(data) + require.NoError(t, err) + + // Empty arrays are also omitted with omitempty + assert.NotContains(t, string(jsonData), "groups") + assert.NotContains(t, string(jsonData), "skills") +} + +func TestUpstreamRegistry_WithSkills(t *testing.T) { + t.Parallel() + reg := &UpstreamRegistry{ + Schema: "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/upstream-registry.schema.json", + Version: "1.0.0", + Meta: UpstreamMeta{ + LastUpdated: time.Now().Format(time.RFC3339), + }, + Data: UpstreamData{ + Servers: []upstreamv0.ServerJSON{}, + Skills: []Skill{ + { + Namespace: "io.github.stacklok", + Name: "pdf-processor", + Description: "Extract text and tables from PDF files", + Version: "1.0.0", + Status: "active", + Packages: []SkillPackage{ + { + RegistryType: "oci", + Identifier: "ghcr.io/stacklok/skills/pdf-processor:1.0.0", + }, + }, + }, + }, + }, + } + + jsonData, err := json.Marshal(reg) + require.NoError(t, err) + + var decoded UpstreamRegistry + err = json.Unmarshal(jsonData, &decoded) + require.NoError(t, err) + require.Len(t, decoded.Data.Skills, 1) + assert.Equal(t, "io.github.stacklok", decoded.Data.Skills[0].Namespace) + assert.Equal(t, "pdf-processor", decoded.Data.Skills[0].Name) + assert.Equal(t, "1.0.0", decoded.Data.Skills[0].Version) + require.Len(t, decoded.Data.Skills[0].Packages, 1) + assert.Equal(t, "oci", decoded.Data.Skills[0].Packages[0].RegistryType) +} + +func TestRegistryGroup_Structure(t *testing.T) { + t.Parallel() + + group := UpstreamGroup{ + Name: "test-group", + Description: "A test group for testing purposes", + Servers: []upstreamv0.ServerJSON{ + { + Name: "io.test/server1", + Description: "Test server 1", + Version: "1.0.0", + }, + }, + } + + jsonData, err := json.Marshal(group) + require.NoError(t, err) + + var decoded UpstreamGroup + err = json.Unmarshal(jsonData, &decoded) + require.NoError(t, err) + assert.Equal(t, group.Name, decoded.Name) + assert.Equal(t, group.Description, decoded.Description) + assert.Len(t, decoded.Servers, 1) + assert.Equal(t, "io.test/server1", decoded.Servers[0].Name) +}