Deduplicate server JSON structs between publisher and registry (#318)
<!-- Provide a brief summary of your changes --> ## Motivation and Context <!-- Why is this change needed? What problem does it solve? --> The following PR eliminates duplicate struct definitions by migrating the publisher tool to use canonical types from pkg/model, creating a single source of truth across the codebase. The publisher tool previously had its own copies of structs that were duplicated, ie. ServerJSON, Package, Repository, VersionDetail, EnvironmentVariable, RuntimeArgument and a few others which was not optimal **Changes in the PR:** - Refactored the structs to single canonical types located at pkg/model - Refactor the implementation to use/build canonical structures directly - Remove all duplicate definitions (5 structs eliminated) - Publisher now uses model.ServerJSON, model.Package, model.Repository, etc. - Renamed some of the structs to better reflect their use case, i.e. ServerJSON instead of ServerDetail - Fixes an issue where the Remote property was missing from the previous publisher copy of the Server **Benefits:** - Single source of truth - Schema changes in one place - Type safety - Compiler catches mismatches - Enhanced validation - Field constraints and bson tags - Zero breaking changes - Same CLI interface and JSON output - Future-proof - New canonical fields automatically available Supersedes: #217 and #240 ## How Has This Been Tested? <!-- Have you tested this in a real application? Which scenarios were tested? --> Locally ## Breaking Changes <!-- Will users need to update their code or configurations? --> No ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [ ] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [ ] My code follows the repository's style guidelines - [ ] New and existing tests pass locally - [ ] I have added appropriate error handling - [ ] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions --> --------- Signed-off-by: Radoslav Dimitrov <radoslav@stacklok.com>
This commit is contained in:
parent
9d04671ee3
commit
099a2d702e
1 changed files with 1280 additions and 1408 deletions
|
|
@ -1,9 +1,14 @@
|
|||
package validators
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1"
|
||||
"github.com/modelcontextprotocol/registry/pkg/model"
|
||||
)
|
||||
|
||||
// ServerValidator validates server details
|
||||
|
|
@ -12,7 +17,7 @@ type ServerValidator struct {
|
|||
}
|
||||
|
||||
// Validate checks if the server details are valid
|
||||
func (v *ServerValidator) Validate(obj *model.ServerDetail) error {
|
||||
func (v *ServerValidator) Validate(obj *model.ServerJSON) error {
|
||||
if err := v.RepositoryValidator.Validate(&obj.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -104,7 +109,7 @@ func NewObjectValidator() *ObjectValidator {
|
|||
}
|
||||
}
|
||||
|
||||
func (ov *ObjectValidator) Validate(obj *model.ServerDetail) error {
|
||||
func (ov *ObjectValidator) Validate(obj *model.ServerJSON) error {
|
||||
if err := ov.ServerValidator.Validate(obj); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -122,3 +127,163 @@ func (ov *ObjectValidator) Validate(obj *model.ServerDetail) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePublisherExtensions validates that publisher extensions are within size limits
|
||||
func ValidatePublisherExtensions(req apiv1.PublishRequest) error {
|
||||
const maxExtensionSize = 4 * 1024 // 4KB limit
|
||||
|
||||
// Check size limit for x-publisher extension
|
||||
if req.XPublisher != nil {
|
||||
extensionsJSON, err := json.Marshal(req.XPublisher)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal x-publisher extension: %w", err)
|
||||
}
|
||||
if len(extensionsJSON) > maxExtensionSize {
|
||||
return fmt.Errorf("x-publisher extension exceeds 4KB limit (%d bytes)", len(extensionsJSON))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePublishRequestExtensions validates that only allowed extension fields are present
|
||||
func ValidatePublishRequestExtensions(requestData []byte) error {
|
||||
// Parse the raw JSON to check for unknown fields
|
||||
var rawRequest map[string]interface{}
|
||||
if err := json.Unmarshal(requestData, &rawRequest); err != nil {
|
||||
return fmt.Errorf("failed to parse request JSON: %w", err)
|
||||
}
|
||||
|
||||
// Define allowed top-level fields
|
||||
allowedFields := map[string]bool{
|
||||
"server": true,
|
||||
"x-publisher": true,
|
||||
}
|
||||
|
||||
// Check for any disallowed fields
|
||||
var invalidFields []string
|
||||
for field := range rawRequest {
|
||||
if !allowedFields[field] {
|
||||
invalidFields = append(invalidFields, field)
|
||||
}
|
||||
}
|
||||
|
||||
if len(invalidFields) > 0 {
|
||||
return fmt.Errorf("invalid extension fields: %v. Only 'server' and 'x-publisher' fields are allowed", invalidFields)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtractPublisherExtensions extracts publisher extensions from a apiv1.PublishRequest
|
||||
func ExtractPublisherExtensions(req apiv1.PublishRequest) map[string]interface{} {
|
||||
publisherExtensions := make(map[string]interface{})
|
||||
if req.XPublisher != nil {
|
||||
// Copy fields directly, avoiding double nesting
|
||||
for k, v := range req.XPublisher {
|
||||
publisherExtensions[k] = v
|
||||
}
|
||||
}
|
||||
return publisherExtensions
|
||||
}
|
||||
|
||||
// ParseServerName extracts the server name from a model.ServerJSON for validation purposes
|
||||
func ParseServerName(serverDetail model.ServerJSON) (string, error) {
|
||||
name := serverDetail.Name
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("server name is required and must be a string")
|
||||
}
|
||||
|
||||
// Validate format: dns-namespace/name
|
||||
if !strings.Contains(name, "/") {
|
||||
return "", fmt.Errorf("server name must be in format 'dns-namespace/name' (e.g., 'com.example.api/server')")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(name, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", fmt.Errorf("server name must be in format 'dns-namespace/name' with non-empty namespace and name parts")
|
||||
}
|
||||
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// ValidateRemoteNamespaceMatch validates that remote URLs match the reverse-DNS namespace
|
||||
func ValidateRemoteNamespaceMatch(serverDetail model.ServerJSON) error {
|
||||
namespace := serverDetail.Name
|
||||
|
||||
for _, remote := range serverDetail.Remotes {
|
||||
if err := validateRemoteURLMatchesNamespace(remote.URL, namespace); err != nil {
|
||||
return fmt.Errorf("remote URL %s does not match namespace %s: %w", remote.URL, namespace, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRemoteURLMatchesNamespace checks if a remote URL's hostname matches the publisher domain from the namespace
|
||||
func validateRemoteURLMatchesNamespace(remoteURL, namespace string) error {
|
||||
// Parse the URL to extract the hostname
|
||||
parsedURL, err := url.Parse(remoteURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL format: %w", err)
|
||||
}
|
||||
|
||||
hostname := parsedURL.Hostname()
|
||||
if hostname == "" {
|
||||
return fmt.Errorf("URL must have a valid hostname")
|
||||
}
|
||||
|
||||
// Skip validation for localhost and local development URLs
|
||||
if hostname == "localhost" || strings.HasSuffix(hostname, ".localhost") || hostname == "127.0.0.1" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract publisher domain from reverse-DNS namespace
|
||||
publisherDomain := extractPublisherDomainFromNamespace(namespace)
|
||||
if publisherDomain == "" {
|
||||
return fmt.Errorf("invalid namespace format: cannot extract domain from %s", namespace)
|
||||
}
|
||||
|
||||
// Check if the remote URL hostname matches the publisher domain or is a subdomain
|
||||
if !isValidHostForDomain(hostname, publisherDomain) {
|
||||
return fmt.Errorf("remote URL host %s does not match publisher domain %s", hostname, publisherDomain)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractPublisherDomainFromNamespace converts reverse-DNS namespace to normal domain format
|
||||
// e.g., "com.example" -> "example.com"
|
||||
func extractPublisherDomainFromNamespace(namespace string) string {
|
||||
// Extract the namespace part before the first slash
|
||||
namespacePart := namespace
|
||||
if slashIdx := strings.Index(namespace, "/"); slashIdx != -1 {
|
||||
namespacePart = namespace[:slashIdx]
|
||||
}
|
||||
|
||||
// Split into parts and reverse them to get normal domain format
|
||||
parts := strings.Split(namespacePart, ".")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Reverse the parts to convert from reverse-DNS to normal domain
|
||||
slices.Reverse(parts)
|
||||
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// isValidHostForDomain checks if a hostname is the domain or a subdomain of the publisher domain
|
||||
func isValidHostForDomain(hostname, publisherDomain string) bool {
|
||||
// Exact match
|
||||
if hostname == publisherDomain {
|
||||
return true
|
||||
}
|
||||
|
||||
// Subdomain match - hostname should end with "." + publisherDomain
|
||||
if strings.HasSuffix(hostname, "."+publisherDomain) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue