diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df86f68c..ac28f4e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: branches: [ main ] env: - GO_VERSION: '1.23.x' + GO_VERSION: '1.24.x' jobs: # Build, Lint, and Validate @@ -17,38 +17,38 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Set up Go uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} - + - name: Cache Go modules uses: actions/cache@v4 with: path: | ~/.cache/go-build ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + key: ${{ runner.os }}-go-${{ github.sha }}-${{ hashFiles('**/go.sum', '.golangci.yml') }} restore-keys: | ${{ runner.os }}-go- - + - name: Download dependencies run: go mod download - + - name: Install golangci-lint run: | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.3.1 - + - name: Run lint run: make lint - + - name: Validate schemas and examples run: make validate - + - name: Build application run: make build - + - name: Check for vulnerabilities run: | go install golang.org/x/vuln/cmd/govulncheck@latest @@ -61,12 +61,12 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Set up Go uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} - + - name: Cache Go modules uses: actions/cache@v4 with: @@ -76,13 +76,13 @@ jobs: key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- - + - name: Download dependencies run: go mod download - + - name: Run all tests run: make test-all - + - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: diff --git a/.golangci.yml b/.golangci.yml index b50c99ee..81a24859 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,3 @@ -# GolangCI-Lint configuration -# See: https://golangci-lint.run/usage/configuration/ - version: "2" run: modules-download-mode: readonly @@ -19,7 +16,6 @@ linters: - errorlint - exhaustive - forbidigo - - funlen - gocognit - goconst - gocritic @@ -32,7 +28,6 @@ linters: - grouper - importas - ireturn - - lll - makezero - misspell - nakedret @@ -48,7 +43,6 @@ linters: - revive - rowserrcheck - sqlclosecheck - - staticcheck - testpackage - thelper - tparallel @@ -59,35 +53,13 @@ linters: - whitespace settings: cyclop: - max-complexity: 50 - funlen: - lines: 150 - statements: 150 + max-complexity: 20 + dupl: + threshold: 300 gocognit: - min-complexity: 50 - goconst: - min-len: 3 - min-occurrences: 3 - gocyclo: - min-complexity: 25 - lll: - line-length: 150 - misspell: - locale: US - mnd: - checks: - - argument - - case - - condition - - operation - - return + min-complexity: 51 nestif: - min-complexity: 8 - revive: - rules: - - name: use-any - severity: error - disabled: false + min-complexity: 10 exclusions: generated: lax presets: @@ -95,33 +67,11 @@ linters: - common-false-positives - legacy - std-error-handling - rules: - - linters: - - dupl - - errcheck - - funlen - - gocyclo - - gosec - - mnd - path: _test\.go - - linters: - - lll - path: docs/ - - linters: - - mnd - path: integrationtests/ - - linters: - - gomoddirectives - path: go\.mod paths: - third_party$ - builtin$ - examples$ formatters: - enable: - - gci - - gofmt - - goimports exclusions: generated: lax paths: diff --git a/Dockerfile b/Dockerfile index 11dde418..884c1cf5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.24-alpine AS builder WORKDIR /app COPY . . ARG GO_BUILD_TAGS diff --git a/README.md b/README.md index 3bf08aba..3c3b0ecd 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,14 @@ The MCP Registry service provides a centralized repository for MCP server entrie - MongoDB and in-memory database support - Comprehensive API documentation - Pagination support for listing registry entries +- Seed data export/import composability with HTTP support +- Registry instance data sharing via HTTP endpoints ## Getting Started ### Prerequisites -- Go 1.23.x (required - check with `go version`) +- Go 1.24.x (required - check with `go version`) - MongoDB - Docker (optional, but recommended for development) @@ -413,8 +415,7 @@ The service can be configured using environment variables: | `MCP_REGISTRY_GITHUB_CLIENT_ID` | GitHub App Client ID | | | `MCP_REGISTRY_GITHUB_CLIENT_SECRET` | GitHub App Client Secret | | | `MCP_REGISTRY_LOG_LEVEL` | Log level | `info` | -| `MCP_REGISTRY_SEED_FILE_PATH` | Path to import seed file | `data/seed.json` | -| `MCP_REGISTRY_SEED_IMPORT` | Import `seed.json` on first run | `true` | +| `MCP_REGISTRY_SEED_FROM` | Path or URL to import seed data (supports local files and HTTP URLs) | `data/seed.json` | | `MCP_REGISTRY_SERVER_ADDRESS` | Listen address for the server | `:8080` | ## Pre-built Docker Images @@ -435,6 +436,35 @@ docker run -p 8080:8080 ghcr.io/modelcontextprotocol/registry:main-20250806-a1b2 **Configuration:** The Docker images support all environment variables listed in the [Configuration](#configuration) section. For production deployments, you'll need to configure the database connection and other settings via environment variables. +### Import Seed Data + +Registry instances can import data from: + +**Local files:** +```bash +MCP_REGISTRY_SEED_FROM=data/seed.json ./registry +``` + +**HTTP endpoints:** +```bash +MCP_REGISTRY_SEED_FROM=http://other-registry:8080 ./registry +``` + +## Testing + +Run the test script to validate API endpoints: + +```bash +./scripts/test_endpoints.sh +``` + +You can specify specific endpoints to test: + +```bash +./scripts/test_endpoints.sh --endpoint health +./scripts/test_endpoints.sh --endpoint servers +``` + ## License See the [LICENSE](LICENSE) file for details. diff --git a/cmd/registry/main.go b/cmd/registry/main.go index 387462da..bcfa3ac7 100644 --- a/cmd/registry/main.go +++ b/cmd/registry/main.go @@ -79,14 +79,14 @@ func main() { return } - // Import seed data if requested (works for both memory and MongoDB) - if cfg.SeedImport { - log.Println("Importing data...") + // Import seed data if seed source is provided (works for both memory and MongoDB) + if cfg.SeedFrom != "" { + log.Printf("Importing data from %s...", cfg.SeedFrom) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - if err := db.ImportSeed(ctx, cfg.SeedFilePath); err != nil { - log.Printf("Failed to import seed file: %v", err) + if err := db.ImportSeed(ctx, cfg.SeedFrom); err != nil { + log.Printf("Failed to import seed data: %v", err) } else { log.Println("Data import completed successfully") } diff --git a/go.mod b/go.mod index 5cbf5146..9bc4e8d9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/modelcontextprotocol/registry -go 1.23.0 +go 1.24.0 require ( github.com/caarlos0/env/v11 v11.3.1 @@ -38,6 +38,3 @@ require ( golang.org/x/tools v0.32.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -// temporary replace directive to use local version of the module so we can share in different orgs -replace github.com/modelcontextprotocol/registry => ./ diff --git a/internal/api/handlers/v0/mocks_test.go b/internal/api/handlers/v0/mocks_test.go new file mode 100644 index 00000000..9d0197bd --- /dev/null +++ b/internal/api/handlers/v0/mocks_test.go @@ -0,0 +1,50 @@ +package v0_test + +import ( + "context" + + "github.com/modelcontextprotocol/registry/internal/model" + "github.com/stretchr/testify/mock" +) + +// MockRegistryService is a mock implementation of the RegistryService interface +type MockRegistryService struct { + mock.Mock +} + +func (m *MockRegistryService) List(cursor string, limit int) ([]model.Server, string, error) { + args := m.Called(cursor, limit) + return args.Get(0).([]model.Server), args.String(1), args.Error(2) +} + +func (m *MockRegistryService) GetByID(id string) (*model.ServerDetail, error) { + args := m.Called(id) + return args.Get(0).(*model.ServerDetail), args.Error(1) +} + +func (m *MockRegistryService) Publish(serverDetail *model.ServerDetail) error { + args := m.Called(serverDetail) + return args.Error(0) +} + +// MockAuthService is a mock implementation of the auth.Service interface +type MockAuthService struct { + mock.Mock +} + +func (m *MockAuthService) StartAuthFlow( + ctx context.Context, method model.AuthMethod, repoRef string, +) (map[string]string, string, error) { + args := m.Called(ctx, method, repoRef) + return args.Get(0).(map[string]string), args.String(1), args.Error(2) +} + +func (m *MockAuthService) CheckAuthStatus(ctx context.Context, statusToken string) (string, error) { + args := m.Called(ctx, statusToken) + return args.String(0), args.Error(1) +} + +func (m *MockAuthService) ValidateAuth(ctx context.Context, authentication model.Authentication) (bool, error) { + args := m.Called(ctx, authentication) + return args.Bool(0), args.Error(1) +} diff --git a/internal/api/handlers/v0/publish_test.go b/internal/api/handlers/v0/publish_test.go index deedf8d7..20bb9300 100644 --- a/internal/api/handlers/v0/publish_test.go +++ b/internal/api/handlers/v0/publish_test.go @@ -17,48 +17,6 @@ import ( "github.com/stretchr/testify/require" ) -// MockRegistryService is a mock implementation of the RegistryService interface -type MockRegistryService struct { - mock.Mock -} - -func (m *MockRegistryService) List(cursor string, limit int) ([]model.Server, string, error) { - args := m.Called(cursor, limit) - return args.Get(0).([]model.Server), args.String(1), args.Error(2) -} - -func (m *MockRegistryService) GetByID(id string) (*model.ServerDetail, error) { - args := m.Called(id) - return args.Get(0).(*model.ServerDetail), args.Error(1) -} - -func (m *MockRegistryService) Publish(serverDetail *model.ServerDetail) error { - args := m.Called(serverDetail) - return args.Error(0) -} - -// MockAuthService is a mock implementation of the auth.Service interface -type MockAuthService struct { - mock.Mock -} - -func (m *MockAuthService) StartAuthFlow( - ctx context.Context, method model.AuthMethod, repoRef string, -) (map[string]string, string, error) { - args := m.Called(ctx, method, repoRef) - return args.Get(0).(map[string]string), args.String(1), args.Error(2) -} - -func (m *MockAuthService) CheckAuthStatus(ctx context.Context, statusToken string) (string, error) { - args := m.Called(ctx, statusToken) - return args.String(0), args.Error(1) -} - -func (m *MockAuthService) ValidateAuth(ctx context.Context, authentication model.Authentication) (bool, error) { - args := m.Called(ctx, authentication) - return args.Bool(0), args.Error(1) -} - func TestPublishHandler(t *testing.T) { testCases := []struct { name string @@ -479,7 +437,12 @@ func TestPublishHandlerBearerTokenParsing(t *testing.T) { requestBody, err := json.Marshal(serverDetail) assert.NoError(t, err) - req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/publish", bytes.NewBuffer(requestBody)) + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + "/publish", + bytes.NewBuffer(requestBody), + ) assert.NoError(t, err) req.Header.Set("Authorization", tc.authHeader) @@ -544,7 +507,12 @@ func TestPublishHandlerAuthMethodSelection(t *testing.T) { requestBody, err := json.Marshal(serverDetail) assert.NoError(t, err) - req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/publish", bytes.NewBuffer(requestBody)) + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + "/publish", + bytes.NewBuffer(requestBody), + ) assert.NoError(t, err) req.Header.Set("Authorization", "Bearer test_token") diff --git a/internal/config/config.go b/internal/config/config.go index 950c3f58..6bf50fce 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,8 +19,7 @@ type Config struct { DatabaseName string `env:"DATABASE_NAME" envDefault:"mcp-registry"` CollectionName string `env:"COLLECTION_NAME" envDefault:"servers_v2"` LogLevel string `env:"LOG_LEVEL" envDefault:"info"` - SeedFilePath string `env:"SEED_FILE_PATH" envDefault:"data/seed.json"` - SeedImport bool `env:"SEED_IMPORT" envDefault:"true"` + SeedFrom string `env:"SEED_FROM" envDefault:""` Version string `env:"VERSION" envDefault:"dev"` GithubClientID string `env:"GITHUB_CLIENT_ID" envDefault:""` GithubClientSecret string `env:"GITHUB_CLIENT_SECRET" envDefault:""` diff --git a/internal/database/import.go b/internal/database/import.go index e29965a8..8c39143b 100644 --- a/internal/database/import.go +++ b/internal/database/import.go @@ -1,18 +1,27 @@ package database import ( + "context" "encoding/json" "fmt" + "io" "log" + "net/http" + "net/url" "os" "path/filepath" + "strings" + "time" "github.com/modelcontextprotocol/registry/internal/model" ) -// ReadSeedFile reads and parses the seed.json file - exported for use by all database implementations -func ReadSeedFile(path string) ([]model.ServerDetail, error) { - log.Printf("Reading seed file from %s", path) +// ReadSeedFile reads seed data from various sources: +// 1. Local file paths (*.json files) +// 2. Direct HTTP URLs to seed.json files +// 3. Registry root URLs (automatically appends /v0/servers and paginates) +func ReadSeedFile(ctx context.Context, path string) ([]model.ServerDetail, error) { + log.Printf("Reading seed data from %s", path) // Set default seed file path if not provided if path == "" { @@ -23,13 +32,60 @@ func ReadSeedFile(path string) ([]model.ServerDetail, error) { } } - // Read the file content + // Check if path is an HTTP URL + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + // Determine if this is a direct seed file URL or a registry root URL + if strings.HasSuffix(path, ".json") || strings.Contains(path, "seed.json") { + // Direct seed file URL - read directly + fileContent, err := readFromHTTP(ctx, path) + if err != nil { + return nil, fmt.Errorf("failed to read from HTTP URL: %w", err) + } + return parseSeedJSON(fileContent) + } + // Registry root URL - paginate through /v0/servers endpoint + return readFromRegistryWithContext(ctx, path) + } + // Read from local file fileContent, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } + return parseSeedJSON(fileContent) +} + +// readFromHTTP reads content from an HTTP URL with timeout +func readFromHTTP(ctx context.Context, url string) ([]byte, error) { + client := &http.Client{ + Timeout: 30 * time.Second, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } - // Parse the JSON content + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, resp.Status) + } + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + return body, nil +} + +// parseSeedJSON parses JSON content into ServerDetail objects +func parseSeedJSON(fileContent []byte) ([]model.ServerDetail, error) { var servers []model.ServerDetail if err := json.Unmarshal(fileContent, &servers); err != nil { // Try parsing as a raw JSON array and then convert to our model @@ -39,6 +95,177 @@ func ReadSeedFile(path string) ([]model.ServerDetail, error) { } } - log.Printf("Found %d server entries in seed file", len(servers)) + log.Printf("Found %d server entries in seed data", len(servers)) return servers, nil } + +// PaginatedResponse represents the paginated response from /v0/servers endpoint +// PaginatedResponse represents the structure of a paginated response from /v0/servers endpoint +type PaginatedResponse struct { + Data []model.Server `json:"servers"` + Metadata Metadata `json:"metadata,omitempty"` +} + +// Metadata contains pagination metadata +type Metadata struct { + NextCursor string `json:"next_cursor,omitempty"` + Count int `json:"count,omitempty"` + Total int `json:"total,omitempty"` +} + +// readFromRegistryWithContext reads all servers from a registry by paginating through /v0/servers endpoint +func readFromRegistryWithContext(ctx context.Context, registryURL string) ([]model.ServerDetail, error) { + log.Printf("Reading from registry: %s", registryURL) + + // Ensure the URL doesn't have a trailing slash + registryURL = strings.TrimSuffix(registryURL, "/") + + var allServers []model.ServerDetail + cursor := "" + pageCount := 0 + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + for { + pageCount++ + + // Add delay between requests as requested (10 seconds by default) + // Can be overridden by SEED_IMPORT_DELAY environment variable for testing + if pageCount > 1 { // Don't delay before the first request + delay := 10 * time.Second + if delayStr := os.Getenv("SEED_IMPORT_DELAY"); delayStr != "" { + if parsedDelay, err := time.ParseDuration(delayStr); err == nil { + delay = parsedDelay + } + } + if delay > 0 { + log.Printf("Waiting %v before fetching page %d...", delay, pageCount) + time.Sleep(delay) + } + } + + log.Printf("Fetching page %d from registry", pageCount) + + // Build the URL for this page + serverURL := registryURL + "/v0/servers" + if cursor != "" { + // Add cursor parameter for pagination + parsed, err := url.Parse(serverURL) + if err != nil { + return nil, fmt.Errorf("failed to parse registry URL: %w", err) + } + query := parsed.Query() + query.Set("cursor", cursor) + query.Set("limit", "100") // Use maximum limit for efficiency + parsed.RawQuery = query.Encode() + serverURL = parsed.String() + } else { + // First page - use max limit + serverURL += "?limit=100" + } + + // Fetch the page + req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for %s: %w", serverURL, err) + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch servers from %s: %w", serverURL, err) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("HTTP request to %s failed with status %d: %s", serverURL, resp.StatusCode, resp.Status) + } + + // Read and parse the response + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("failed to read response body from %s: %w", serverURL, err) + } + + var pageResponse PaginatedResponse + if err := json.Unmarshal(body, &pageResponse); err != nil { + return nil, fmt.Errorf("failed to parse servers response from %s: %w", serverURL, err) + } + + log.Printf("Retrieved %d servers from page %d", len(pageResponse.Data), pageCount) + + // For each server in this page, get the detailed information + for _, server := range pageResponse.Data { + // Build URL for server detail + detailURL := registryURL + "/v0/servers/" + server.ID + + detailReq, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil) + if err != nil { + log.Printf("Warning: failed to create request for server %s: %v", server.ID, err) + // Fall back to basic server information + serverDetail := model.ServerDetail{ + Server: server, + } + allServers = append(allServers, serverDetail) + continue + } + + detailResp, err := client.Do(detailReq) + if err != nil { + log.Printf("Warning: failed to fetch details for server %s: %v", server.ID, err) + // Fall back to basic server information + serverDetail := model.ServerDetail{ + Server: server, + } + allServers = append(allServers, serverDetail) + continue + } + + if detailResp.StatusCode != http.StatusOK { + log.Printf("Warning: failed to fetch details for server %s (status %d)", server.ID, detailResp.StatusCode) + detailResp.Body.Close() + // Fall back to basic server information + serverDetail := model.ServerDetail{ + Server: server, + } + allServers = append(allServers, serverDetail) + continue + } + + detailBody, err := io.ReadAll(detailResp.Body) + detailResp.Body.Close() + if err != nil { + log.Printf("Warning: failed to read detail response for server %s: %v", server.ID, err) + // Fall back to basic server information + serverDetail := model.ServerDetail{ + Server: server, + } + allServers = append(allServers, serverDetail) + continue + } + + var serverDetail model.ServerDetail + if err := json.Unmarshal(detailBody, &serverDetail); err != nil { + log.Printf("Warning: failed to parse detail response for server %s: %v", server.ID, err) + // Fall back to basic server information + serverDetail = model.ServerDetail{ + Server: server, + } + } + + allServers = append(allServers, serverDetail) + } + + // Check if there are more pages + if pageResponse.Metadata.NextCursor == "" { + log.Printf("Reached end of pagination after %d pages", pageCount) + break + } + + cursor = pageResponse.Metadata.NextCursor + } + + log.Printf("Successfully retrieved %d servers from registry %s", len(allServers), registryURL) + return allServers, nil +} diff --git a/internal/database/import_test.go b/internal/database/import_test.go new file mode 100644 index 00000000..cda9eeaf --- /dev/null +++ b/internal/database/import_test.go @@ -0,0 +1,188 @@ +package database_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/modelcontextprotocol/registry/internal/database" + "github.com/modelcontextprotocol/registry/internal/model" + "github.com/stretchr/testify/assert" +) + +func TestReadSeedFile_LocalFile(t *testing.T) { + // Create a temporary seed file + tempFile := "/tmp/test_seed.json" + seedData := []model.ServerDetail{ + { + Server: model.Server{ + ID: "test-id-1", + Name: "test-server-1", + Description: "Test server 1", + Repository: model.Repository{ + URL: "https://github.com/test/repo1", + Source: "github", + ID: "123", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2023-01-01T00:00:00Z", + IsLatest: true, + }, + }, + }, + } + + // Write seed data to temp file + data, err := json.Marshal(seedData) + assert.NoError(t, err) + + err = func() error { + f, err := os.Create(tempFile) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(data) + return err + }() + assert.NoError(t, err) + defer os.Remove(tempFile) + + // Test reading the file + result, err := database.ReadSeedFile(context.Background(), tempFile) + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, "test-server-1", result[0].Name) +} + +func TestReadSeedFile_DirectHTTPURL(t *testing.T) { + // Create a test HTTP server that serves seed JSON directly + seedData := []model.ServerDetail{ + { + Server: model.Server{ + ID: "test-id-1", + Name: "test-server-1", + Description: "Test server 1", + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(seedData); err != nil { + http.Error(w, "Failed to encode JSON", http.StatusInternalServerError) + } + })) + defer server.Close() + + // Test reading from HTTP URL ending in .json + result, err := database.ReadSeedFile(context.Background(), server.URL+"/seed.json") + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, "test-server-1", result[0].Name) +} + +func TestReadSeedFile_RegistryURL(t *testing.T) { + // Create mock registry servers + server1 := model.Server{ + ID: "server-1", + Name: "Test Server 1", + Description: "First test server", + } + server2 := model.Server{ + ID: "server-2", + Name: "Test Server 2", + Description: "Second test server", + } + + serverDetail1 := model.ServerDetail{ + Server: server1, + Packages: []model.Package{ + { + Name: "test-package-1", + Version: "1.0.0", + }, + }, + } + serverDetail2 := model.ServerDetail{ + Server: server2, + Packages: []model.Package{ + { + Name: "test-package-2", + Version: "2.0.0", + }, + }, + } + + // Create a test HTTP server that simulates the registry API + mux := http.NewServeMux() + + // Handle /v0/servers endpoint (paginated) + mux.HandleFunc("/v0/servers", func(w http.ResponseWriter, r *http.Request) { + cursor := r.URL.Query().Get("cursor") + + var response database.PaginatedResponse + switch cursor { + case "": + // First page + response = database.PaginatedResponse{ + Data: []model.Server{server1}, + Metadata: database.Metadata{ + NextCursor: "next-cursor-1", + Count: 1, + }, + } + case "next-cursor-1": + // Second page + response = database.PaginatedResponse{ + Data: []model.Server{server2}, + Metadata: database.Metadata{ + Count: 1, + // No NextCursor means end of pagination + }, + } + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, "Failed to encode JSON", http.StatusInternalServerError) + } + }) + + // Handle individual server detail endpoints + mux.HandleFunc("/v0/servers/server-1", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(serverDetail1); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + + mux.HandleFunc("/v0/servers/server-2", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(serverDetail2); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + + server := httptest.NewServer(mux) + defer server.Close() + + // Test reading from registry root URL (this should trigger pagination) + result, err := database.ReadSeedFile(context.Background(), server.URL) + assert.NoError(t, err) + assert.Len(t, result, 2) + + // Verify the servers were imported correctly + assert.Equal(t, "Test Server 1", result[0].Name) + assert.Equal(t, "Test Server 2", result[1].Name) + + // Verify packages were included + assert.Len(t, result[0].Packages, 1) + assert.Equal(t, "test-package-1", result[0].Packages[0].Name) + assert.Len(t, result[1].Packages, 1) + assert.Equal(t, "test-package-2", result[1].Packages[0].Name) +} diff --git a/internal/database/memory.go b/internal/database/memory.go index bb077cb4..7a334156 100644 --- a/internal/database/memory.go +++ b/internal/database/memory.go @@ -252,7 +252,7 @@ func (db *MemoryDB) ImportSeed(ctx context.Context, seedFilePath string) error { } // Read the seed file - seedData, err := ReadSeedFile(seedFilePath) + seedData, err := ReadSeedFile(ctx, seedFilePath) if err != nil { return fmt.Errorf("failed to read seed file: %w", err) } diff --git a/internal/database/mongo.go b/internal/database/mongo.go index 21538493..3e93aa6c 100644 --- a/internal/database/mongo.go +++ b/internal/database/mongo.go @@ -235,7 +235,7 @@ func (db *MongoDB) Publish(ctx context.Context, serverDetail *model.ServerDetail // ImportSeed imports initial data from a seed file into MongoDB func (db *MongoDB) ImportSeed(ctx context.Context, seedFilePath string) error { // Read the seed file - servers, err := ReadSeedFile(seedFilePath) + servers, err := ReadSeedFile(ctx, seedFilePath) if err != nil { return fmt.Errorf("failed to read seed file: %w", err) } diff --git a/registry b/registry new file mode 100755 index 00000000..04e5ceda Binary files /dev/null and b/registry differ diff --git a/tests/integration/README.md b/tests/integration/README.md index e1cf7598..3df4a351 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -23,7 +23,7 @@ This directory contains an end-to-end test for publishing to the registry. ### Prerequisites - Docker and Docker Compose -- Go 1.23 +- Go 1.24 - Make sure you're in the repository root directory ### Run the Tests diff --git a/tools/publisher/main.go b/tools/publisher/main.go index f74db89b..f0494855 100644 --- a/tools/publisher/main.go +++ b/tools/publisher/main.go @@ -103,7 +103,8 @@ func publishCommand() error { publishFlags.StringVar(®istryURL, "registry-url", "", "URL of the registry (required)") publishFlags.StringVar(&mcpFilePath, "mcp-file", "", "path to the MCP file (required)") publishFlags.BoolVar(&forceLogin, "login", false, "force a new login even if a token exists") - publishFlags.StringVar(&authMethod, "auth-method", "github-oauth", "authentication method to use (default: github-oauth)") + publishFlags.StringVar(&authMethod, "auth-method", "github-oauth", + "authentication method to use (default: github-oauth)") // Set custom usage function publishFlags.Usage = func() { @@ -210,16 +211,20 @@ func createCommand() error { createFlags.StringVar(&execute, "e", "", "Command to execute the server (shorthand)") // Custom flag for environment variables - createFlags.Func("env-var", "Environment variable in format NAME:DESCRIPTION (can be repeated)", func(value string) error { - envVars = append(envVars, value) - return nil - }) + createFlags.Func("env-var", + "Environment variable in format NAME:DESCRIPTION (can be repeated)", + func(value string) error { + envVars = append(envVars, value) + return nil + }) // Custom flag for package arguments - createFlags.Func("package-arg", "Package argument in format VALUE:DESCRIPTION (can be repeated)", func(value string) error { - packageArgs = append(packageArgs, value) - return nil - }) + createFlags.Func("package-arg", + "Package argument in format VALUE:DESCRIPTION (can be repeated)", + func(value string) error { + packageArgs = append(packageArgs, value) + return nil + }) // Set custom usage function createFlags.Usage = func() { @@ -240,7 +245,8 @@ func createCommand() error { fmt.Fprint(os.Stdout, " --package-version string Package version (defaults to server version)\n") fmt.Fprint(os.Stdout, " --runtime-hint string Runtime hint (e.g., docker)\n") fmt.Fprint(os.Stdout, " --repo-source string Repository source (default: github)\n") - fmt.Fprint(os.Stdout, " --env-var string Environment variable in format NAME:DESCRIPTION (can be repeated)\n") + fmt.Fprint(os.Stdout, " --env-var string Environment variable in format "+ + "NAME:DESCRIPTION (can be repeated)\n") fmt.Fprint(os.Stdout, " --package-arg string Package argument in format VALUE:DESCRIPTION (can be repeated)\n") } @@ -360,8 +366,11 @@ func publishToRegistry(registryURL string, mcpData []byte, token string) error { return nil } -func createServerStructure(name, description, version, repoURL, repoSource, registryName, - packageName, packageVersion, runtimeHint, execute string, envVars []string, packageArgs []string, status string) ServerJSON { +func createServerStructure( + name, description, version, repoURL, repoSource, registryName, + packageName, packageVersion, runtimeHint, execute string, + envVars []string, packageArgs []string, status string, +) ServerJSON { // Parse environment variables var environmentVariables []EnvironmentVariable for _, envVar := range envVars { diff --git a/tools/validate-examples/main.go b/tools/validate-examples/main.go index a7394249..e555279f 100644 --- a/tools/validate-examples/main.go +++ b/tools/validate-examples/main.go @@ -14,7 +14,7 @@ import ( "regexp" "strings" - "github.com/santhosh-tekuri/jsonschema/v5" + jsonschema "github.com/santhosh-tekuri/jsonschema/v5" ) const ( diff --git a/tools/validate-schemas/main.go b/tools/validate-schemas/main.go index 90f21f8b..4e106218 100644 --- a/tools/validate-schemas/main.go +++ b/tools/validate-schemas/main.go @@ -13,7 +13,7 @@ import ( "path/filepath" "strings" - "github.com/santhosh-tekuri/jsonschema/v5" + jsonschema "github.com/santhosh-tekuri/jsonschema/v5" ) func main() {