feat: add admin edit endpoint for server moderation (#315)
## Summary
Implements `PUT /v0/servers/{id}` admin edit endpoint to address part of
issue #182, providing an API-based solution for server moderation
without requiring direct database access.
- **New endpoint**: `PUT /v0/servers/{id}` for editing existing servers
- **Security**: Uses existing JWT auth with `PermissionActionEdit`
permissions
- **Anti-undelete protection**: Added `ServerStatusDeleted` status that
cannot be reverted
- **Full implementation**: Database, service, API, and comprehensive
test coverage
## Key Use Cases
- Mark malicious servers as `deleted` for takedown
- Update server metadata for moderation actions
Related to: https://github.com/modelcontextprotocol/registry/issues/182
(but that is not _done_ yet because we need admins to be able to login
(probably with #283), and need to add docs on how to do the takedown,
and probably guidelines on what we will/won't takedown)
This commit is contained in:
parent
36fcdff044
commit
6431f23107
3 changed files with 681 additions and 23 deletions
|
|
@ -146,6 +146,35 @@ func (s *fakeRegistryService) Publish(req model.PublishRequest) (*model.ServerRe
|
|||
return &response, nil
|
||||
}
|
||||
|
||||
// EditServer updates an existing server with new details (admin operation)
|
||||
func (s *fakeRegistryService) EditServer(id string, req model.PublishRequest) (*model.ServerResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Validate the request
|
||||
if err := model.ValidatePublisherExtensions(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate server name exists and format
|
||||
if _, err := model.ParseServerName(req.Server); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Extract publisher extensions from request
|
||||
publisherExtensions := model.ExtractPublisherExtensions(req)
|
||||
|
||||
// Update server in database
|
||||
serverRecord, err := s.db.UpdateServer(ctx, id, req.Server, publisherExtensions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert ServerRecord to ServerResponse format
|
||||
response := serverRecord.ToServerResponse()
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// Close closes the in-memory database connection
|
||||
func (s *fakeRegistryService) Close() error {
|
||||
return s.db.Close()
|
||||
|
|
|
|||
|
|
@ -90,36 +90,36 @@ func validateMCPBPackage(host string) error {
|
|||
if !isAllowed {
|
||||
return fmt.Errorf("MCPB packages must be hosted on allowlisted providers (GitHub or GitLab). Host '%s' is not allowed", host)
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePackage validates packages to ensure they meet requirements
|
||||
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://") {
|
||||
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" || strings.HasSuffix(strings.ToLower(pkg.Identifier), ".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
|
||||
|
|
@ -254,3 +254,49 @@ func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context,
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EditServer updates an existing server with new details (admin operation)
|
||||
func (s *registryServiceImpl) EditServer(id string, req model.PublishRequest) (*model.ServerResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Validate the request
|
||||
if err := model.ValidatePublisherExtensions(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate server name exists and format
|
||||
if _, err := model.ParseServerName(req.Server); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate all packages
|
||||
for _, pkg := range req.Server.Packages {
|
||||
if err := validatePackage(&pkg); err != nil {
|
||||
return nil, fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reverse-DNS namespace matching for remote URLs
|
||||
if err := model.ValidateRemoteNamespaceMatch(req.Server); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for duplicate remote URLs
|
||||
if err := s.validateNoDuplicateRemoteURLs(ctx, req.Server); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Extract publisher extensions from request
|
||||
publisherExtensions := model.ExtractPublisherExtensions(req)
|
||||
|
||||
// Update server in database
|
||||
serverRecord, err := s.db.UpdateServer(ctx, id, req.Server, publisherExtensions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert ServerRecord to ServerResponse format
|
||||
response := serverRecord.ToServerResponse()
|
||||
return &response, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import "github.com/modelcontextprotocol/registry/internal/model"
|
|||
type RegistryService interface {
|
||||
// List retrieves servers with extension wrapper format
|
||||
List(cursor string, limit int) ([]model.ServerResponse, string, error)
|
||||
// GetByID retrieves a single server by registry metadata ID with extension wrapper format
|
||||
// GetByID retrieves a single server by registry metadata ID with extension wrapper format
|
||||
GetByID(id string) (*model.ServerResponse, error)
|
||||
// Publish publishes a server with separated extensions
|
||||
Publish(req model.PublishRequest) (*model.ServerResponse, error)
|
||||
// EditServer updates an existing server with new details
|
||||
EditServer(id string, req model.PublishRequest) (*model.ServerResponse, error)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue