registry/internal/service/registry_service.go
joan-anthropic 57efb58143
feat: Add support for MCP Bundles (MCPB) in registry (#295)
## Motivation and Context
- Add MCPB as a new registry_name option
- Add file_hashes field to package model for integrity verification
- Implement URL validation restricting MCPB packages to GitHub/GitLab
hosts
- Require SHA-256 hash for all MCPB packages
- Add example MCPB package in documentation
- Update schemas and OpenAPI specification

See issue #260

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

## Breaking Changes
N/A, registry has not yet been launched

## 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)
- [x] 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. -->
- [x] 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
- [x] I have added or updated documentation as needed

---------

Co-authored-by: Adam Jones <adamj+git@anthropic.com>
2025-08-27 04:44:53 +01:00

256 lines
7.6 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/model"
)
const maxServerVersionsPerServer = 10000
// registryServiceImpl implements the RegistryService interface using our Database
type registryServiceImpl struct {
db database.Database
}
// NewRegistryServiceWithDB creates a new registry service with the provided database
func NewRegistryServiceWithDB(db database.Database) RegistryService {
return &registryServiceImpl{
db: db,
}
}
// List returns registry entries with cursor-based pagination in extension wrapper format
func (s *registryServiceImpl) List(cursor string, limit int) ([]model.ServerResponse, string, error) {
// Create a timeout context for the database operation
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// If limit is not set or negative, use a default limit
if limit <= 0 {
limit = 30
}
// Use the database's List method with pagination
serverRecords, nextCursor, err := s.db.List(ctx, nil, cursor, limit)
if err != nil {
return nil, "", err
}
// Convert ServerRecord to ServerResponse format
result := make([]model.ServerResponse, len(serverRecords))
for i, record := range serverRecords {
result[i] = record.ToServerResponse()
}
return result, nextCursor, nil
}
// GetByID retrieves a specific server by its registry metadata ID in extension wrapper format
func (s *registryServiceImpl) GetByID(id string) (*model.ServerResponse, error) {
// Create a timeout context for the database operation
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Use the database's GetByID method to retrieve the server record
serverRecord, err := s.db.GetByID(ctx, id)
if err != nil {
return nil, err
}
// Convert ServerRecord to ServerResponse format
response := serverRecord.ToServerResponse()
return &response, nil
}
// validateMCPBPackage validates MCPB packages
func validateMCPBPackage(host string) error {
allowedHosts := []string{
"github.com",
"www.github.com",
"gitlab.com",
"www.gitlab.com",
}
isAllowed := false
for _, allowed := range allowedHosts {
if host == allowed {
isAllowed = true
break
}
}
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://") {
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
}
// Publish publishes a server with separated extensions
func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerResponse, error) {
// Create a timeout context for the database operation
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
}
// Get the new version's details
newVersion := req.Server.VersionDetail.Version
newName := req.Server.Name
currentTime := time.Now()
// Check for existing versions of this server
existingServers, _, err := s.db.List(ctx, map[string]any{"name": newName}, "", maxServerVersionsPerServer)
if err != nil && !errors.Is(err, database.ErrNotFound) {
return nil, err
}
if len(existingServers) == maxServerVersionsPerServer {
return nil, database.ErrMaxServersReached
}
// Determine if this version should be marked as latest
isLatest := true
var existingLatestID string
// Check all existing versions for duplicates and determine if new version should be latest
for _, server := range existingServers {
existingVersion := server.ServerJSON.VersionDetail.Version
// Early exit: check for duplicate version
if existingVersion == newVersion {
return nil, database.ErrInvalidVersion
}
if server.RegistryMetadata.IsLatest {
existingLatestID = server.RegistryMetadata.ID
existingTime, _ := time.Parse(time.RFC3339, server.RegistryMetadata.ReleaseDate)
// Compare versions using the proper versioning strategy
comparison := CompareVersions(newVersion, existingVersion, currentTime, existingTime)
if comparison <= 0 {
// New version is not greater than existing latest
isLatest = false
}
}
}
// Extract publisher extensions from request
publisherExtensions := model.ExtractPublisherExtensions(req)
// Create registry metadata with service-determined values
registryMetadata := model.RegistryMetadata{
ID: uuid.New().String(),
PublishedAt: currentTime,
UpdatedAt: currentTime,
IsLatest: isLatest,
ReleaseDate: currentTime.Format(time.RFC3339),
}
// If this will be the latest version, we need to update the existing latest
if isLatest && existingLatestID != "" {
// Update the existing latest to no longer be latest
if err := s.db.UpdateLatestFlag(ctx, existingLatestID, false); err != nil {
return nil, err
}
}
// Publish to database with the registry metadata
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions, registryMetadata)
if err != nil {
return nil, err
}
// Convert ServerRecord to ServerResponse format
response := serverRecord.ToServerResponse()
return &response, nil
}
// validateNoDuplicateRemoteURLs checks that no other server is using the same remote URLs
func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context, serverDetail model.ServerDetail) error {
// Check each remote URL in the new server for conflicts
for _, remote := range serverDetail.Remotes {
// Use filter to find servers with this remote URL
filter := map[string]any{
"remote_url": remote.URL,
}
conflictingServers, _, err := s.db.List(ctx, filter, "", 1000)
if err != nil {
return fmt.Errorf("failed to check remote URL conflict: %w", err)
}
// Check if any conflicting server has a different name
for _, conflictingServer := range conflictingServers {
if conflictingServer.ServerJSON.Name != serverDetail.Name {
return fmt.Errorf("remote URL %s is already used by server %s", remote.URL, conflictingServer.ServerJSON.Name)
}
}
}
return nil
}