Clean up publish and edit handler and service logic (#319)

Thanks Camila for being a great Go consultant and advising on how to
tidy this up!

(I'm still a lil grumpy about some parts, but it's in much better shape
😄)

- Consolidated publisher extension validation into a single function.
- Updated the `Publish` and `EditServer` methods to directly use
`XPublisher` from the request.
- Removed outdated tests for publisher extension extraction and
validation.
- Introduced new validation functions for server details and remote URL
matching.
- Enhanced error handling for server name format and remote URL
validation.
- Updated existing tests to reflect changes in validation logic and
structure.

## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->

Things were starting to become a bit spaghetti-y after lots of small PRs
and contributions from many people. Camila and I went through and
figured out how we could make this a lot cleaner.

## How Has This Been Tested?
<!-- Have you tested this in a real application? Which scenarios were
tested? -->

Tested locally, plus all the integration and unit tests still pass.

## Breaking Changes
<!-- Will users need to update their code or configurations? -->

None

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
adam jones 2025-08-28 20:20:43 +01:00 • committed by GitHub
commit 654cad4aa7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -11,33 +11,47 @@ import (
"github.com/modelcontextprotocol/registry/pkg/model"
)
// ServerValidator validates server details
type ServerValidator struct {
*RepositoryValidator // Embedded RepositoryValidator for repository validation
}
// Validate checks if the server details are valid
func (v *ServerValidator) Validate(obj *model.ServerJSON) error {
if err := v.RepositoryValidator.Validate(&obj.Repository); err != nil {
func ValidateServerJSON(serverJSON *model.ServerJSON) error {
// Validate server name exists and format
if _, err := parseServerName(*serverJSON); err != nil {
return err
}
// Validate repository
if err := validateRepository(&serverJSON.Repository); err != nil {
return err
}
// Validate all packages (basic field validation)
for _, pkg := range serverJSON.Packages {
if err := validatePackageField(&pkg); err != nil {
return err
}
}
// Validate all packages (URL and registry type validation)
for _, pkg := range serverJSON.Packages {
if err := validatePackage(&pkg); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
}
// Validate all remotes
for _, remote := range serverJSON.Remotes {
if err := validateRemote(&remote); err != nil {
return err
}
}
// Validate reverse-DNS namespace matching for remote URLs
if err := validateRemoteNamespaceMatch(*serverJSON); err != nil {
return err
}
return nil
}
// NewServerValidator creates a new ServerValidator instance
func NewServerValidator() *ServerValidator {
return &ServerValidator{
RepositoryValidator: NewRepositoryValidator(),
}
}
// RepositoryValidator validates repository details
type RepositoryValidator struct {
validSources map[RepositorySource]bool
}
// Validate checks if the repository details are valid
func (rv *RepositoryValidator) Validate(obj *model.Repository) error {
func validateRepository(obj *model.Repository) error {
// Skip validation for empty repository (optional field)
if obj.URL == "" && obj.Source == "" {
return nil
@ -52,18 +66,7 @@ func (rv *RepositoryValidator) Validate(obj *model.Repository) error {
return nil
}
// NewRepositoryValidator creates a new RepositoryValidator instance
func NewRepositoryValidator() *RepositoryValidator {
return &RepositoryValidator{
validSources: map[RepositorySource]bool{SourceGitHub: true, SourceGitLab: true},
}
}
// PackageValidator validates package details
type PackageValidator struct{}
// Validate checks if the package details are valid
func (pv *PackageValidator) Validate(obj *model.Package) error {
func validatePackageField(obj *model.Package) error {
if !HasNoSpaces(obj.Identifier) {
return ErrPackageNameHasSpaces
}
@ -71,65 +74,82 @@ func (pv *PackageValidator) Validate(obj *model.Package) error {
return nil
}
// NewPackageValidator creates a new PackageValidator instance
func NewPackageValidator() *PackageValidator {
return &PackageValidator{}
}
// RemoteValidator validates remote connection details
type RemoteValidator struct{}
// Validate checks if the remote connection details are valid
func (rv *RemoteValidator) Validate(obj *model.Remote) error {
func validateRemote(obj *model.Remote) error {
if !IsValidURL(obj.URL) {
return fmt.Errorf("%w: %s", ErrInvalidRemoteURL, obj.URL)
}
return nil
}
// NewRemoteValidator creates a new RemoteValidator instance
func NewRemoteValidator() *RemoteValidator {
return &RemoteValidator{}
}
// ObjectValidator aggregates multiple validators for different object types
// This allows for a single entry point to validate complex objects that may contain multiple fields
// that need validation.
type ObjectValidator struct {
ServerValidator *ServerValidator
PackageValidator *PackageValidator
RemoteValidator *RemoteValidator
}
func NewObjectValidator() *ObjectValidator {
return &ObjectValidator{
ServerValidator: NewServerValidator(),
PackageValidator: NewPackageValidator(),
RemoteValidator: NewRemoteValidator(),
}
}
func (ov *ObjectValidator) Validate(obj *model.ServerJSON) error {
if err := ov.ServerValidator.Validate(obj); err != nil {
return err
func validateMCPBPackage(host string) error {
allowedHosts := []string{
"github.com",
"www.github.com",
"gitlab.com",
"www.gitlab.com",
}
for _, pkg := range obj.Packages {
if err := ov.PackageValidator.Validate(&pkg); err != nil {
return err
isAllowed := false
for _, allowed := range allowedHosts {
if host == allowed {
isAllowed = true
break
}
}
for _, remote := range obj.Remotes {
if err := ov.RemoteValidator.Validate(&remote); err != nil {
return err
}
if !isAllowed {
return fmt.Errorf("MCPB packages must be hosted on allowlisted providers (GitHub or GitLab). Host '%s' is not allowed", host)
}
return nil
}
// ValidatePublisherExtensions validates that publisher extensions are within size limits
func ValidatePublisherExtensions(req apiv0.PublishRequest) error {
func validatePackage(pkg *model.Package) error {
registryType := strings.ToLower(pkg.RegistryType)
// For direct download packages (mcpb or direct URLs)
if registryType == "mcpb" ||
strings.HasPrefix(pkg.Identifier, "http://") || strings.HasPrefix(pkg.Identifier, "https://") {
parsedURL, err := url.Parse(pkg.Identifier)
if err != nil {
return fmt.Errorf("invalid package URL: %w", err)
}
host := strings.ToLower(parsedURL.Host)
// For MCPB packages, validate they're from allowed hosts
if registryType == "mcpb" {
return validateMCPBPackage(host)
}
// For other URL-based packages, just ensure it's valid
if parsedURL.Scheme == "" || parsedURL.Host == "" {
return fmt.Errorf("package URL must be a valid absolute URL")
}
return nil
}
// For registry-based packages, no special validation needed
// Registry types like "npm", "pypi", "docker-hub", "nuget" are all valid
return nil
}
// ValidatePublishRequest validates a complete publish request including extensions
func ValidatePublishRequest(req apiv0.PublishRequest) error {
// Validate publisher extensions
if err := validatePublisherExtensions(req); err != nil {
return err
}
// Validate the server detail (includes all nested validation)
if err := ValidateServerJSON(&req.Server); err != nil {
return err
}
return nil
}
func validatePublisherExtensions(req apiv0.PublishRequest) error {
const maxExtensionSize = 4 * 1024 // 4KB limit
// Check size limit for x-publisher extension
@ -146,50 +166,8 @@ func ValidatePublisherExtensions(req apiv0.PublishRequest) error {
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 apiv0.PublishRequest
func ExtractPublisherExtensions(req apiv0.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
func parseServerName(serverJSON model.ServerJSON) (string, error) {
name := serverJSON.Name
if name == "" {
return "", fmt.Errorf("server name is required and must be a string")
}
@ -207,11 +185,11 @@ func ParseServerName(serverDetail model.ServerJSON) (string, error) {
return name, nil
}
// ValidateRemoteNamespaceMatch validates that remote URLs match the reverse-DNS namespace
func ValidateRemoteNamespaceMatch(serverDetail model.ServerJSON) error {
namespace := serverDetail.Name
// validateRemoteNamespaceMatch validates that remote URLs match the reverse-DNS namespace
func validateRemoteNamespaceMatch(serverJSON model.ServerJSON) error {
namespace := serverJSON.Name
for _, remote := range serverDetail.Remotes {
for _, remote := range serverJSON.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)
}