Add more validation for free-form server fields (#272)

<!-- Provide a brief summary of your changes -->
The changes are intended to create validators for all the free-form
fields. Please find the details of the validation of different field
below.

- Repository.Source: only github and gitlab is accepted.
- Repository.Url - It should be a valid github or gitlab URL. Created
regex for both.
- Server.Name - Max 200 char can be there. Reverse domain name
verification is part of another PR (not owned by me).
- Package.Name - Max 200 char and no spaces.
- Server.Description - Max 100 char.
- Remote.Url - It should be valid URL.

## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->
The motivation was to make the code clean and add base code for more
validator for different objects like Repository etc.
https://github.com/modelcontextprotocol/registry/issues/97

## How Has This Been Tested?
It's tested via UTs. Added UT's for all the validations.

## Breaking Changes
Yes

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [x] 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. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [ ] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [ ] I have added or updated documentation as needed

Co-authored-by: Avish Porwal <avishporwal@microsoft.com>
Co-authored-by: Adam Jones <adamj+git@anthropic.com>
This commit is contained in:
Avish Porwal 2025-08-27 06:28:41 +05:30 • committed by GitHub
commit c3b72ef3a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 484 additions and 12 deletions

View file

@ -0,0 +1,23 @@
package validators
import "errors"
// Error messages for validation
var (
// Repository validation errors
ErrInvalidRepositoryURL = errors.New("invalid repository URL")
// Package validation errors
ErrPackageNameHasSpaces = errors.New("package name cannot contain spaces")
// Remote validation errors
ErrInvalidRemoteURL = errors.New("invalid remote URL")
)
// RepositorySource represents valid repository sources
type RepositorySource string
const (
SourceGitHub RepositorySource = "github"
SourceGitLab RepositorySource = "gitlab"
)

View file

@ -0,0 +1,50 @@
package validators
import (
"net/url"
"regexp"
"strings"
)
var (
// Regular expressions for validating repository URLs
// These regex patterns ensure the URL is in the format of a valid GitHub or GitLab repository
// For example: // - GitHub: https://github.com/user/repo
githubURLRegex = regexp.MustCompile(`^https?://(www\.)?github\.com/[\w.-]+/[\w.-]+/?$`)
gitlabURLRegex = regexp.MustCompile(`^https?://(www\.)?gitlab\.com/[\w.-]+/[\w.-]+/?$`)
)
// IsValidRepositoryURL checks if the given URL is valid for the specified repository source
func IsValidRepositoryURL(source RepositorySource, url string) bool {
switch source {
case SourceGitHub:
return githubURLRegex.MatchString(url)
case SourceGitLab:
return gitlabURLRegex.MatchString(url)
}
return false
}
// HasNoSpaces checks if a string contains no spaces
func HasNoSpaces(s string) bool {
return !strings.Contains(s, " ")
}
// IsValidURL checks if a URL is in valid format
func IsValidURL(rawURL string) bool {
// Parse the URL
u, err := url.Parse(rawURL)
if err != nil {
return false
}
// Check if scheme is present (http or https)
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
if u.Host == "" {
return false
}
return true
}

View file

@ -0,0 +1,124 @@
package validators
import (
"fmt"
"github.com/modelcontextprotocol/registry/internal/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.ServerDetail) error {
if err := v.RepositoryValidator.Validate(&obj.Repository); 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 {
// Skip validation for empty repository (optional field)
if obj.URL == "" && obj.Source == "" {
return nil
}
// validate the repository source
repoSource := RepositorySource(obj.Source)
if !IsValidRepositoryURL(repoSource, obj.URL) {
return fmt.Errorf("%w: %s", ErrInvalidRepositoryURL, obj.URL)
}
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 {
if !HasNoSpaces(obj.Name) {
return ErrPackageNameHasSpaces
}
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 {
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.ServerDetail) error {
if err := ov.ServerValidator.Validate(obj); err != nil {
return err
}
for _, pkg := range obj.Packages {
if err := ov.PackageValidator.Validate(&pkg); err != nil {
return err
}
}
for _, remote := range obj.Remotes {
if err := ov.RemoteValidator.Validate(&remote); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,248 @@
package validators_test
import (
"testing"
"github.com/modelcontextprotocol/registry/internal/model"
"github.com/modelcontextprotocol/registry/internal/validators"
"github.com/stretchr/testify/assert"
)
func TestObjectValidator_Validate(t *testing.T) {
validator := validators.NewObjectValidator()
tests := []struct {
name string
serverDetail model.ServerDetail
expectedError string
}{
{
name: "valid server detail with all fields",
serverDetail: model.ServerDetail{
Name: "test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://github.com/owner/repo",
Source: "github",
ID: "owner/repo",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Packages: []model.Package{
{
Name: "test-package",
RegistryName: "npm",
},
},
Remotes: []model.Remote{
{
URL: "https://example.com/remote",
},
},
},
expectedError: "",
},
{
name: "server with invalid repository source",
serverDetail: model.ServerDetail{
Name: "test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://bitbucket.org/owner/repo",
Source: "bitbucket", // Not in validSources
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
expectedError: validators.ErrInvalidRepositoryURL.Error(),
},
{
name: "server with invalid GitHub URL format",
serverDetail: model.ServerDetail{
Name: "test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://github.com/owner", // Missing repo name
Source: "github",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
expectedError: validators.ErrInvalidRepositoryURL.Error(),
},
{
name: "server with invalid GitLab URL format",
serverDetail: model.ServerDetail{
Name: "test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://gitlab.com", // Missing owner and repo
Source: "gitlab",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
expectedError: validators.ErrInvalidRepositoryURL.Error(),
},
{
name: "package with spaces in name",
serverDetail: model.ServerDetail{
Name: "test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://github.com/owner/repo",
Source: "github",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Packages: []model.Package{
{
Name: "test package with spaces",
RegistryName: "npm",
},
},
},
expectedError: validators.ErrPackageNameHasSpaces.Error(),
},
{
name: "multiple packages with one invalid",
serverDetail: model.ServerDetail{
Name: "test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://github.com/owner/repo",
Source: "github",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Packages: []model.Package{
{
Name: "valid-package",
RegistryName: "npm",
},
{
Name: "invalid package", // Has space
RegistryName: "pip",
},
},
},
expectedError: validators.ErrPackageNameHasSpaces.Error(),
},
{
name: "remote with invalid URL",
serverDetail: model.ServerDetail{
Name: "test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://github.com/owner/repo",
Source: "github",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Remotes: []model.Remote{
{
URL: "not-a-valid-url",
},
},
},
expectedError: validators.ErrInvalidRemoteURL.Error(),
},
{
name: "remote with missing scheme",
serverDetail: model.ServerDetail{
Name: "test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://github.com/owner/repo",
Source: "github",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Remotes: []model.Remote{
{
URL: "example.com/remote",
},
},
},
expectedError: validators.ErrInvalidRemoteURL.Error(),
},
{
name: "multiple remotes with one invalid",
serverDetail: model.ServerDetail{
Name: "test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://github.com/owner/repo",
Source: "github",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Remotes: []model.Remote{
{
URL: "https://valid.com/remote",
},
{
URL: "invalid-url",
},
},
},
expectedError: validators.ErrInvalidRemoteURL.Error(),
},
{
name: "server detail with nil packages and remotes",
serverDetail: model.ServerDetail{
Name: "test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://github.com/owner/repo",
Source: "github",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Packages: nil,
Remotes: nil,
},
expectedError: "",
},
{
name: "server detail with empty packages and remotes slices",
serverDetail: model.ServerDetail{
Name: "test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://github.com/owner/repo",
Source: "github",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Packages: []model.Package{},
Remotes: []model.Remote{},
},
expectedError: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.Validate(&tt.serverDetail)
if tt.expectedError == "" {
assert.NoError(t, err)
} else {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
}
})
}
}