diff --git a/docs/server-json/server.schema.json b/docs/server-json/server.schema.json index fd8c98b..637c3d5 100644 --- a/docs/server-json/server.schema.json +++ b/docs/server-json/server.schema.json @@ -53,12 +53,16 @@ "name": { "type": "string", "description": "Server name/identifier", - "example": "io.modelcontextprotocol/filesystem" + "example": "io.modelcontextprotocol/filesystem", + "minLength": 1, + "maxLength": 200 }, "description": { "type": "string", "description": "Human-readable description of the server's functionality", - "example": "Node.js server implementing Model Context Protocol (MCP) for filesystem operations." + "example": "Node.js server implementing Model Context Protocol (MCP) for filesystem operations.", + "minLength": 1, + "maxLength": 100 }, "status": { "type": "string", @@ -95,7 +99,8 @@ "version": { "type": "string", "description": "Package version", - "example": "1.0.2" + "example": "1.0.2", + "minLength": 1 }, "runtime_hint": { "type": "string", diff --git a/internal/api/handlers/v0/publish.go b/internal/api/handlers/v0/publish.go index 2332e7d..8057439 100644 --- a/internal/api/handlers/v0/publish.go +++ b/internal/api/handlers/v0/publish.go @@ -11,6 +11,7 @@ import ( "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/model" "github.com/modelcontextprotocol/registry/internal/service" + "github.com/modelcontextprotocol/registry/internal/validators" ) // PublishServerInput represents the input for publishing a server @@ -63,6 +64,12 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg // Get server details from request body serverDetail := publishRequest.Server + // Validate the server detail + validator := validators.NewObjectValidator() + if err := validator.Validate(&serverDetail); err != nil { + return nil, huma.Error400BadRequest(err.Error()) + } + // Verify that the token's repository matches the server being published if !jwtManager.HasPermission(serverDetail.Name, auth.PermissionActionPublish, claims.Permissions) { return nil, huma.Error403Forbidden("You do not have permission to publish this server") diff --git a/internal/api/handlers/v0/publish_integration_test.go b/internal/api/handlers/v0/publish_integration_test.go index ece2173..f7f3394 100644 --- a/internal/api/handlers/v0/publish_integration_test.go +++ b/internal/api/handlers/v0/publish_integration_test.go @@ -105,9 +105,9 @@ func TestPublishIntegration(t *testing.T) { Name: "com.example/test-mcp-server-no-auth", Description: "A test MCP server without authentication", Repository: model.Repository{ - URL: "https://example.com/test-mcp-server", - Source: "example", - ID: "test-mcp-server", + URL: "https://github.com/example/test-server", + Source: "github", + ID: "example/test-server", }, VersionDetail: model.VersionDetail{ Version: "1.0.0", @@ -194,6 +194,11 @@ func TestPublishIntegration(t *testing.T) { VersionDetail: model.VersionDetail{ Version: "1.0.0", }, + Repository: model.Repository{ + URL: "https://github.com/example/test-server", + Source: "github", + ID: "example/test-server", + }, }, } diff --git a/internal/api/handlers/v0/publish_test.go b/internal/api/handlers/v0/publish_test.go index 3554151..ed7d910 100644 --- a/internal/api/handlers/v0/publish_test.go +++ b/internal/api/handlers/v0/publish_test.go @@ -112,8 +112,8 @@ func TestPublishEndpoint(t *testing.T) { Name: "example/test-server", Description: "A test server without auth", Repository: model.Repository{ - URL: "https://example.com/test-server", - Source: "example", + URL: "https://github.com/example/test-server", + Source: "github", ID: "example/test-server", }, VersionDetail: model.VersionDetail{ @@ -173,6 +173,11 @@ func TestPublishEndpoint(t *testing.T) { VersionDetail: model.VersionDetail{ Version: "1.0.0", }, + Repository: model.Repository{ + URL: "https://github.com/example/test-server", + Source: "github", + ID: "example/test-server", + }, }, }, tokenClaims: &auth.JWTClaims{ @@ -194,6 +199,11 @@ func TestPublishEndpoint(t *testing.T) { VersionDetail: model.VersionDetail{ Version: "1.0.0", }, + Repository: model.Repository{ + URL: "https://github.com/example/test-server", + Source: "github", + ID: "example/test-server", + }, }, }, tokenClaims: &auth.JWTClaims{ diff --git a/internal/model/model.go b/internal/model/model.go index 0e61d95..81a730f 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -100,7 +100,7 @@ type Package struct { // Remote represents a remote connection endpoint type Remote struct { TransportType string `json:"transport_type" bson:"transport_type"` - URL string `json:"url" bson:"url"` + URL string `json:"url" format:"uri" bson:"url"` Headers []KeyValueInput `json:"headers,omitempty" bson:"headers,omitempty"` } @@ -112,9 +112,9 @@ type VersionDetail struct { // ServerDetail represents complete server information as defined in the MCP spec (pure, no registry metadata) type ServerDetail struct { Schema string `json:"$schema,omitempty" bson:"$schema,omitempty"` - Name string `json:"name" bson:"name"` - Description string `json:"description" bson:"description"` - Status ServerStatus `json:"status,omitempty" bson:"status,omitempty"` + Name string `json:"name" minLength:"1" maxLength:"200" bson:"name"` + Description string `json:"description" minLength:"1" maxLength:"100" bson:"description"` + Status ServerStatus `json:"status,omitempty" minLength:"1" bson:"status,omitempty"` Repository Repository `json:"repository,omitempty" bson:"repository"` VersionDetail VersionDetail `json:"version_detail" bson:"version_detail"` Packages []Package `json:"packages,omitempty" bson:"packages,omitempty"` diff --git a/internal/validators/constants.go b/internal/validators/constants.go new file mode 100644 index 0000000..145752e --- /dev/null +++ b/internal/validators/constants.go @@ -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" +) diff --git a/internal/validators/utils.go b/internal/validators/utils.go new file mode 100644 index 0000000..1cc83b3 --- /dev/null +++ b/internal/validators/utils.go @@ -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 +} diff --git a/internal/validators/validators.go b/internal/validators/validators.go new file mode 100644 index 0000000..e9aeee3 --- /dev/null +++ b/internal/validators/validators.go @@ -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 +} diff --git a/internal/validators/validators_test.go b/internal/validators/validators_test.go new file mode 100644 index 0000000..c0462d9 --- /dev/null +++ b/internal/validators/validators_test.go @@ -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) + } + }) + } +}