From d0a0adf6207f7e26a82a87d6ebaea18859ac01e2 Mon Sep 17 00:00:00 2001 From: Avinash Sridhar Date: Mon, 26 May 2025 12:00:25 -0400 Subject: [PATCH] feat(tests): add integration tests for publish functionality and enhance error handling --- integrationtests/README.md | 98 +++ integrationtests/publish_integration_test.go | 736 +++++++++++++++++++ integrationtests/run_tests.sh | 24 + internal/api/handlers/v0/publish.go | 7 + internal/database/database.go | 9 +- internal/database/memory.go | 111 ++- 6 files changed, 957 insertions(+), 28 deletions(-) create mode 100644 integrationtests/README.md create mode 100644 integrationtests/publish_integration_test.go create mode 100755 integrationtests/run_tests.sh diff --git a/integrationtests/README.md b/integrationtests/README.md new file mode 100644 index 0000000..3fa2fd5 --- /dev/null +++ b/integrationtests/README.md @@ -0,0 +1,98 @@ +# Integration Tests + +This directory contains integration tests for the MCP Registry API using the fake service implementation. + +## Overview + +The integration tests are designed to test the complete flow of the publish endpoint using real service implementations (fake service) rather than mocks. This provides confidence that the entire request/response cycle works correctly. + +## Test Structure + +### `publish_integration_test.go` + +Contains comprehensive integration tests for the publish endpoint: + +- **TestPublishIntegration**: Tests various scenarios for publishing servers + - Successful publish with GitHub authentication + - Successful publish without authentication (for non-GitHub servers) + - Error cases: missing name, missing version, missing auth header, invalid JSON, unsupported HTTP methods + - Duplicate package handling: fails when same name+version, succeeds with different versions + +- **TestPublishIntegrationWithComplexPackages**: Tests publishing servers with complex package configurations + - Multiple runtime arguments (named and positional) + - Package arguments + - Environment variables (including secrets) + - Multiple remotes with different transport types + - Headers for HTTP remotes + +- **TestPublishIntegrationEndToEnd**: Tests the complete end-to-end flow + - Publishes a server and verifies it can be retrieved + - Checks that the server appears in the registry list + - Verifies count consistency + +## Mock Services + +### MockAuthService + +A simple mock implementation of the `auth.Service` interface that: +- Accepts any non-empty token for GitHub authentication +- Always allows authentication for `AuthMethodNone` +- Provides realistic responses for auth flow methods + +## Running the Tests + +From the project root directory: + +```bash +# Run all integration tests +go test ./integrationtests/... + +# Run with verbose output +go test -v ./integrationtests/... + +# Run a specific test +go test -v ./integrationtests/ -run TestPublishIntegration + +# Run tests with race detection +go test -race ./integrationtests/... + +# Use the convenient test runner script +./integrationtests/run_tests.sh +``` + +## Test Data + +The tests use the fake service which comes pre-populated with sample data: +- 3 sample MCP servers with different configurations +- Uses in-memory database for isolation between tests +- Each test creates unique server instances with UUIDs + +## Benefits of Integration Tests + +1. **Real Flow Testing**: Tests the actual HTTP request/response cycle +2. **Service Integration**: Validates that handlers work correctly with service implementations +3. **Data Persistence**: Verifies that published data can be retrieved +4. **Error Handling**: Tests complete error scenarios end-to-end +5. **Complex Scenarios**: Tests realistic server configurations with packages and remotes + +## Dependencies + +These tests use: +- `testify/assert` and `testify/require` for assertions +- `httptest` for HTTP testing utilities +- The fake service implementation for realistic data operations +- Standard Go testing package + +## Test Coverage + +The integration tests cover: +- ✅ Successful publish scenarios +- ✅ Authentication validation +- ✅ Input validation +- ✅ Duplicate package handling +- ✅ Complex package configurations +- ✅ Multiple remotes +- ✅ Error handling +- ✅ End-to-end data flow +- ✅ HTTP method validation +- ✅ JSON parsing errors diff --git a/integrationtests/publish_integration_test.go b/integrationtests/publish_integration_test.go new file mode 100644 index 0000000..574d75a --- /dev/null +++ b/integrationtests/publish_integration_test.go @@ -0,0 +1,736 @@ +package integrationtests + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" + "github.com/modelcontextprotocol/registry/internal/auth" + "github.com/modelcontextprotocol/registry/internal/model" + "github.com/modelcontextprotocol/registry/internal/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// MockAuthService implements a simple auth service for testing +type MockAuthService struct{} + +func (m *MockAuthService) StartAuthFlow(ctx context.Context, method model.AuthMethod, repoRef string) (map[string]string, string, error) { + return map[string]string{ + "device_code": "mock_device_code", + "user_code": "ABCD-1234", + "verification_uri": "https://github.com/login/device", + }, "mock_status_token", nil +} + +func (m *MockAuthService) CheckAuthStatus(ctx context.Context, statusToken string) (string, error) { + if statusToken == "mock_status_token" { + return "mock_access_token", nil + } + return "", fmt.Errorf("invalid status token") +} + +func (m *MockAuthService) ValidateAuth(ctx context.Context, authentication model.Authentication) (bool, error) { + // Simple validation: for testing purposes, accept any non-empty token + switch authentication.Method { + case model.AuthMethodGitHub: + return authentication.Token != "", nil + case model.AuthMethodNone: + return true, nil + default: + return false, auth.ErrUnsupportedAuthMethod + } +} + +// TestPublishIntegration tests the complete flow of publishing a server using the fake service +func TestPublishIntegration(t *testing.T) { + // Setup fake service and auth service + registryService := service.NewFakeRegistryService() + authService := &MockAuthService{} + + // Create the publish handler + handler := v0.PublishHandler(registryService, authService) + + t.Run("successful publish with GitHub auth", func(t *testing.T) { + serverDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), + Name: "io.github.testuser/test-mcp-server", + Description: "A test MCP server for integration testing", + Repository: model.Repository{ + URL: "https://github.com/testuser/test-mcp-server", + Source: "github", + ID: "testuser/test-mcp-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + }, + }, + Packages: []model.Package{ + { + RegistryName: "npm", + Name: "test-mcp-server", + Version: "1.0.0", + RunTimeHint: "node", + RuntimeArguments: []model.Argument{ + { + Type: model.ArgumentTypeNamed, + Name: "config", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "Configuration file path", + Format: model.FormatFilePath, + IsRequired: true, + }, + }, + }, + }, + }, + }, + Remotes: []model.Remote{ + { + TransportType: "http", + URL: "http://localhost:3000/mcp", + }, + }, + } + + // Marshal the server detail to JSON + jsonData, err := json.Marshal(serverDetail) + require.NoError(t, err) + + // Create a request + req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer github_test_token_123") + + // Create a response recorder + recorder := httptest.NewRecorder() + + // Call the handler + handler(recorder, req) + + // Check the response + assert.Equal(t, http.StatusCreated, recorder.Code) + + var response map[string]string + err = json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "Server publication successful", response["message"]) + assert.Equal(t, serverDetail.ID, response["id"]) + + // Verify the server was actually published by retrieving it + publishedServer, err := registryService.GetByID(serverDetail.ID) + require.NoError(t, err) + assert.Equal(t, serverDetail.Name, publishedServer.Name) + assert.Equal(t, serverDetail.Description, publishedServer.Description) + assert.Equal(t, serverDetail.VersionDetail.Version, publishedServer.VersionDetail.Version) + assert.Len(t, publishedServer.Packages, 1) + assert.Len(t, publishedServer.Remotes, 1) + }) + + t.Run("successful publish without auth (no prefix)", func(t *testing.T) { + serverDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), + Name: "custom-mcp-server", + Description: "A custom MCP server without auth", + Repository: model.Repository{ + URL: "https://example.com/custom-server", + Source: "custom", + ID: "custom/custom-server", + }, + VersionDetail: model.VersionDetail{ + Version: "2.0.0", + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + }, + }, + } + + jsonData, err := json.Marshal(serverDetail) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "dummy_token") + + recorder := httptest.NewRecorder() + handler(recorder, req) + + assert.Equal(t, http.StatusCreated, recorder.Code) + + var response map[string]string + err = json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "Server publication successful", response["message"]) + assert.Equal(t, serverDetail.ID, response["id"]) + }) + + t.Run("publish fails with missing name", func(t *testing.T) { + serverDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), + Name: "", // Missing name + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + }, + }, + } + + jsonData, err := json.Marshal(serverDetail) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer token") + + recorder := httptest.NewRecorder() + handler(recorder, req) + + assert.Equal(t, http.StatusBadRequest, recorder.Code) + assert.Contains(t, recorder.Body.String(), "Name is required") + }) + + t.Run("publish fails with missing version", func(t *testing.T) { + serverDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "", // Missing version + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + }, + }, + } + + jsonData, err := json.Marshal(serverDetail) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer token") + + recorder := httptest.NewRecorder() + handler(recorder, req) + + assert.Equal(t, http.StatusBadRequest, recorder.Code) + assert.Contains(t, recorder.Body.String(), "Version is required") + }) + + t.Run("publish fails with missing authorization header", func(t *testing.T) { + serverDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + }, + }, + } + + jsonData, err := json.Marshal(serverDetail) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + // No Authorization header + + recorder := httptest.NewRecorder() + handler(recorder, req) + + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + assert.Contains(t, recorder.Body.String(), "Authorization header is required") + }) + + t.Run("publish fails with invalid JSON", func(t *testing.T) { + invalidJSON := `{"name": "test", "version": ` + + req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBufferString(invalidJSON)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer token") + + recorder := httptest.NewRecorder() + handler(recorder, req) + + assert.Equal(t, http.StatusBadRequest, recorder.Code) + assert.Contains(t, recorder.Body.String(), "Invalid") + }) + + t.Run("publish fails with unsupported HTTP method", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v0/publish", nil) + req.Header.Set("Authorization", "Bearer token") + + recorder := httptest.NewRecorder() + handler(recorder, req) + + assert.Equal(t, http.StatusMethodNotAllowed, recorder.Code) + assert.Contains(t, recorder.Body.String(), "Method not allowed") + }) + + t.Run("publish fails with duplicate name and version", func(t *testing.T) { + // First, publish a server successfully + firstServerDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), + Name: "io.github.duplicate/test-server", + Description: "First server for duplicate test", + Repository: model.Repository{ + URL: "https://github.com/duplicate/test-server", + Source: "github", + ID: "duplicate/test-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + }, + }, + } + + jsonData, err := json.Marshal(firstServerDetail) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer github_token_first") + + recorder := httptest.NewRecorder() + handler(recorder, req) + + assert.Equal(t, http.StatusCreated, recorder.Code, "First publish should succeed") + + // Now try to publish another server with the same name and version + duplicateServerDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), // Different ID + Name: "io.github.duplicate/test-server", // Same name + Description: "Duplicate server attempt", + Repository: model.Repository{ + URL: "https://github.com/duplicate/test-server-fork", + Source: "github", + ID: "duplicate/test-server-fork", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", // Same version + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + }, + }, + } + + duplicateJsonData, err := json.Marshal(duplicateServerDetail) + require.NoError(t, err) + + duplicateReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(duplicateJsonData)) + duplicateReq.Header.Set("Content-Type", "application/json") + duplicateReq.Header.Set("Authorization", "Bearer github_token_duplicate") + + duplicateRecorder := httptest.NewRecorder() + handler(duplicateRecorder, duplicateReq) + + // The duplicate should fail + assert.Equal(t, http.StatusBadRequest, duplicateRecorder.Code) + assert.Contains(t, duplicateRecorder.Body.String(), "Failed to publish server details") + + // Verify that only the first server was actually stored + retrievedServer, err := registryService.GetByID(firstServerDetail.ID) + require.NoError(t, err) + assert.Equal(t, firstServerDetail.Name, retrievedServer.Name) + assert.Equal(t, firstServerDetail.Description, retrievedServer.Description) + + // Try to get the duplicate - it should not exist + _, err = registryService.GetByID(duplicateServerDetail.ID) + assert.Error(t, err, "Duplicate server should not have been stored") + }) + + t.Run("publish succeeds with same name but different version", func(t *testing.T) { + // Publish first version + firstVersionDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), + Name: "io.github.versioned/test-server", + Description: "First version of the server", + Repository: model.Repository{ + URL: "https://github.com/versioned/test-server", + Source: "github", + ID: "versioned/test-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: false, + }, + }, + } + + jsonData, err := json.Marshal(firstVersionDetail) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer github_token_v1") + + recorder := httptest.NewRecorder() + handler(recorder, req) + + assert.Equal(t, http.StatusCreated, recorder.Code, "First version should succeed") + + // Publish second version with same name but different version + secondVersionDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), + Name: "io.github.versioned/test-server", // Same name + Description: "Second version of the server", + Repository: model.Repository{ + URL: "https://github.com/versioned/test-server", + Source: "github", + ID: "versioned/test-server", + }, + VersionDetail: model.VersionDetail{ + Version: "2.0.0", // Different version + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + }, + }, + } + + secondJsonData, err := json.Marshal(secondVersionDetail) + require.NoError(t, err) + + secondReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(secondJsonData)) + secondReq.Header.Set("Content-Type", "application/json") + secondReq.Header.Set("Authorization", "Bearer github_token_v2") + + secondRecorder := httptest.NewRecorder() + handler(secondRecorder, secondReq) + + // The second version should succeed + assert.Equal(t, http.StatusCreated, secondRecorder.Code) + + // Verify both versions exist + firstRetrieved, err := registryService.GetByID(firstVersionDetail.ID) + require.NoError(t, err) + assert.Equal(t, "1.0.0", firstRetrieved.VersionDetail.Version) + + secondRetrieved, err := registryService.GetByID(secondVersionDetail.ID) + require.NoError(t, err) + assert.Equal(t, "2.0.0", secondRetrieved.VersionDetail.Version) + }) + + t.Run("publish fails when trying to publish older version after newer version", func(t *testing.T) { + // First, publish a newer version (2.0.0) + newerVersionDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), + Name: "io.github.versioning/version-order-test", + Description: "Newer version published first", + Repository: model.Repository{ + URL: "https://github.com/versioning/version-order-test", + Source: "github", + ID: "versioning/version-order-test", + }, + VersionDetail: model.VersionDetail{ + Version: "2.0.0", + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + }, + }, + } + + jsonData, err := json.Marshal(newerVersionDetail) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer github_token_newer") + + recorder := httptest.NewRecorder() + handler(recorder, req) + + assert.Equal(t, http.StatusCreated, recorder.Code, "Newer version should be published successfully") + + // Now try to publish an older version (1.0.0) of the same package + olderVersionDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), + Name: "io.github.versioning/version-order-test", // Same name + Description: "Older version published after newer", + Repository: model.Repository{ + URL: "https://github.com/versioning/version-order-test", + Source: "github", + ID: "versioning/version-order-test", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", // Older version + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: false, + }, + }, + } + + olderJsonData, err := json.Marshal(olderVersionDetail) + require.NoError(t, err) + + olderReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(olderJsonData)) + olderReq.Header.Set("Content-Type", "application/json") + olderReq.Header.Set("Authorization", "Bearer github_token_older") + + olderRecorder := httptest.NewRecorder() + handler(olderRecorder, olderReq) + + // This should fail - we shouldn't allow publishing older versions after newer ones + assert.Equal(t, http.StatusBadRequest, olderRecorder.Code, "Publishing older version should fail") + assert.Contains(t, olderRecorder.Body.String(), "version", "Error message should mention version") + + // Verify that only the newer version exists + newerRetrieved, err := registryService.GetByID(newerVersionDetail.ID) + require.NoError(t, err) + assert.Equal(t, "2.0.0", newerRetrieved.VersionDetail.Version) + + // Verify the older version was not stored + _, err = registryService.GetByID(olderVersionDetail.ID) + assert.Error(t, err, "Older version should not have been stored") + }) +} + +// TestPublishIntegrationWithComplexPackages tests publishing with complex package configurations +func TestPublishIntegrationWithComplexPackages(t *testing.T) { + registryService := service.NewFakeRegistryService() + authService := &MockAuthService{} + handler := v0.PublishHandler(registryService, authService) + + t.Run("publish with complex package configuration", func(t *testing.T) { + serverDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), + Name: "io.github.complex/advanced-mcp-server", + Description: "An advanced MCP server with complex configuration", + Repository: model.Repository{ + URL: "https://github.com/complex/advanced-mcp-server", + Source: "github", + ID: "complex/advanced-mcp-server", + }, + VersionDetail: model.VersionDetail{ + Version: "2.1.0", + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + }, + }, + Packages: []model.Package{ + { + RegistryName: "npm", + Name: "advanced-mcp-server", + Version: "2.1.0", + RunTimeHint: "node --experimental-modules", + RuntimeArguments: []model.Argument{ + { + Type: model.ArgumentTypeNamed, + Name: "config", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "Main configuration file", + Format: model.FormatFilePath, + IsRequired: true, + Default: "./config.json", + }, + }, + }, + { + Type: model.ArgumentTypePositional, + Name: "mode", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "Operation mode", + Format: model.FormatString, + IsRequired: false, + Default: "production", + Choices: []string{"development", "staging", "production"}, + }, + }, + }, + }, + PackageArguments: []model.Argument{ + { + Type: model.ArgumentTypeNamed, + Name: "install-deps", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "Install dependencies", + Format: model.FormatBoolean, + Default: "true", + }, + }, + }, + }, + EnvironmentVariables: []model.KeyValueInput{ + { + Name: "LOG_LEVEL", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "Logging level", + Format: model.FormatString, + Default: "info", + Choices: []string{"debug", "info", "warn", "error"}, + }, + }, + }, + { + Name: "API_KEY", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "API key for external service", + Format: model.FormatString, + IsRequired: true, + IsSecret: true, + }, + }, + }, + }, + }, + }, + Remotes: []model.Remote{ + { + TransportType: "http", + URL: "http://localhost:8080/mcp", + Headers: []model.Input{ + { + Description: "API Version Header", + Format: model.FormatString, + Value: "v1", + }, + }, + }, + { + TransportType: "websocket", + URL: "ws://localhost:8081/mcp", + }, + }, + } + + jsonData, err := json.Marshal(serverDetail) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer github_complex_token") + + recorder := httptest.NewRecorder() + handler(recorder, req) + + assert.Equal(t, http.StatusCreated, recorder.Code) + + var response map[string]string + err = json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "Server publication successful", response["message"]) + assert.Equal(t, serverDetail.ID, response["id"]) + + // Verify the complex server was published correctly + publishedServer, err := registryService.GetByID(serverDetail.ID) + require.NoError(t, err) + + // Verify package details + require.Len(t, publishedServer.Packages, 1) + pkg := publishedServer.Packages[0] + assert.Equal(t, "npm", pkg.RegistryName) + assert.Equal(t, "advanced-mcp-server", pkg.Name) + assert.Equal(t, "node --experimental-modules", pkg.RunTimeHint) + assert.Len(t, pkg.RuntimeArguments, 2) + assert.Len(t, pkg.PackageArguments, 1) + assert.Len(t, pkg.EnvironmentVariables, 2) + + // Verify remotes + require.Len(t, publishedServer.Remotes, 2) + assert.Equal(t, "http", publishedServer.Remotes[0].TransportType) + assert.Equal(t, "websocket", publishedServer.Remotes[1].TransportType) + assert.Len(t, publishedServer.Remotes[0].Headers, 1) + }) +} + +// TestPublishIntegrationEndToEnd tests the complete end-to-end flow +func TestPublishIntegrationEndToEnd(t *testing.T) { + registryService := service.NewFakeRegistryService() + authService := &MockAuthService{} + handler := v0.PublishHandler(registryService, authService) + + t.Run("end-to-end publish and retrieve flow", func(t *testing.T) { + // Step 1: Get initial count of servers + initialServers, _, err := registryService.List("", 100) + require.NoError(t, err) + initialCount := len(initialServers) + + // Step 2: Publish a new server + serverDetail := &model.ServerDetail{ + Server: model.Server{ + ID: uuid.New().String(), + Name: "io.github.e2e/end-to-end-server", + Description: "End-to-end test server", + Repository: model.Repository{ + URL: "https://github.com/e2e/end-to-end-server", + Source: "github", + ID: "e2e/end-to-end-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + }, + }, + } + + jsonData, err := json.Marshal(serverDetail) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer github_e2e_token") + + recorder := httptest.NewRecorder() + handler(recorder, req) + + require.Equal(t, http.StatusCreated, recorder.Code) + + // Step 3: Verify the count increased + updatedServers, _, err := registryService.List("", 100) + require.NoError(t, err) + assert.Equal(t, initialCount+1, len(updatedServers)) + + // Step 4: Verify the server can be retrieved by ID + retrievedServer, err := registryService.GetByID(serverDetail.ID) + require.NoError(t, err) + assert.Equal(t, serverDetail.Name, retrievedServer.Name) + assert.Equal(t, serverDetail.Description, retrievedServer.Description) + + // Step 5: Verify the server appears in the list + found := false + for _, server := range updatedServers { + if server.ID == serverDetail.ID { + found = true + assert.Equal(t, serverDetail.Name, server.Name) + break + } + } + assert.True(t, found, "Published server should appear in the list") + }) +} diff --git a/integrationtests/run_tests.sh b/integrationtests/run_tests.sh new file mode 100755 index 0000000..d035bb7 --- /dev/null +++ b/integrationtests/run_tests.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Integration Test Runner for MCP Registry +# This script runs the integration tests for the publish functionality + +echo "Running MCP Registry Integration Tests..." +echo "========================================" + +# Change to the project directory (parent of integrationtests) +cd "$(dirname "$0")/.." + +# Run integration tests with verbose output +echo "Running publish integration tests..." +go test -v ./integrationtests/... + +# Check exit code +if [ $? -eq 0 ]; then + echo "" + echo "✅ All integration tests passed!" +else + echo "" + echo "❌ Some integration tests failed!" + exit 1 +fi diff --git a/internal/api/handlers/v0/publish.go b/internal/api/handlers/v0/publish.go index 9e999d5..b62ac6e 100644 --- a/internal/api/handlers/v0/publish.go +++ b/internal/api/handlers/v0/publish.go @@ -3,11 +3,13 @@ package v0 import ( "encoding/json" + "errors" "io" "net/http" "strings" "github.com/modelcontextprotocol/registry/internal/auth" + "github.com/modelcontextprotocol/registry/internal/database" "github.com/modelcontextprotocol/registry/internal/model" "github.com/modelcontextprotocol/registry/internal/service" ) @@ -106,6 +108,11 @@ func PublishHandler(registry service.RegistryService, authService auth.Service) // Call the publish method on the registry service err = registry.Publish(&serverDetail) if err != nil { + // Check for specific error types and return appropriate HTTP status codes + if errors.Is(err, database.ErrInvalidVersion) || errors.Is(err, database.ErrAlreadyExists) { + http.Error(w, "Failed to publish server details: "+err.Error(), http.StatusBadRequest) + return + } http.Error(w, "Failed to publish server details: "+err.Error(), http.StatusInternalServerError) return } diff --git a/internal/database/database.go b/internal/database/database.go index 9c457bc..1d5fc4f 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -9,10 +9,11 @@ import ( // Common database errors var ( - ErrNotFound = errors.New("record not found") - ErrAlreadyExists = errors.New("record already exists") - ErrInvalidInput = errors.New("invalid input") - ErrDatabase = errors.New("database error") + ErrNotFound = errors.New("record not found") + ErrAlreadyExists = errors.New("record already exists") + ErrInvalidInput = errors.New("invalid input") + ErrDatabase = errors.New("database error") + ErrInvalidVersion = errors.New("invalid version: cannot publish older version after newer version") ) // Database defines the interface for database operations on MCPRegistry entries diff --git a/internal/database/memory.go b/internal/database/memory.go index 6df02a2..fbec139 100644 --- a/internal/database/memory.go +++ b/internal/database/memory.go @@ -3,6 +3,8 @@ package database import ( "context" "sort" + "strconv" + "strings" "sync" "github.com/modelcontextprotocol/registry/internal/model" @@ -10,15 +12,73 @@ import ( // MemoryDB is an in-memory implementation of the Database interface type MemoryDB struct { - entries map[string]*model.Server + entries map[string]*model.ServerDetail mu sync.RWMutex } // NewMemoryDB creates a new instance of the in-memory database func NewMemoryDB(e map[string]*model.Server) *MemoryDB { - return &MemoryDB{ - entries: e, + // Convert Server entries to ServerDetail entries + serverDetails := make(map[string]*model.ServerDetail) + for k, v := range e { + serverDetails[k] = &model.ServerDetail{ + Server: *v, + } } + return &MemoryDB{ + entries: serverDetails, + } +} + +// compareSemanticVersions compares two semantic version strings +// Returns: +// +// -1 if version1 < version2 +// 0 if version1 == version2 +// +1 if version1 > version2 +func compareSemanticVersions(version1, version2 string) int { + // Simple semantic version comparison + // Assumes format: major.minor.patch + + parts1 := strings.Split(version1, ".") + parts2 := strings.Split(version2, ".") + + // Pad with zeros if needed + maxLen := len(parts1) + if len(parts2) > maxLen { + maxLen = len(parts2) + } + + for len(parts1) < maxLen { + parts1 = append(parts1, "0") + } + for len(parts2) < maxLen { + parts2 = append(parts2, "0") + } + + // Compare each part + for i := 0; i < maxLen; i++ { + num1, err1 := strconv.Atoi(parts1[i]) + num2, err2 := strconv.Atoi(parts2[i]) + + // If parsing fails, fall back to string comparison + if err1 != nil || err2 != nil { + if parts1[i] < parts2[i] { + return -1 + } else if parts1[i] > parts2[i] { + return 1 + } + continue + } + + if num1 < num2 { + return -1 + } else if num1 > num2 { + return 1 + } + } + + return 0 } // List retrieves all MCPRegistry entries with optional filtering and pagination @@ -37,8 +97,8 @@ func (db *MemoryDB) List(ctx context.Context, filter map[string]interface{}, cur // Convert all entries to a slice for pagination var allEntries []*model.Server for _, entry := range db.entries { - entryCopy := *entry - allEntries = append(allEntries, &entryCopy) + serverCopy := entry.Server + allEntries = append(allEntries, &serverCopy) } // Simple filtering implementation @@ -124,15 +184,9 @@ func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerDetail defer db.mu.RUnlock() if entry, exists := db.entries[id]; exists { - return &model.ServerDetail{ - Server: model.Server{ - ID: entry.ID, - Name: entry.Name, - Description: entry.Description, - VersionDetail: entry.VersionDetail, - Repository: entry.Repository, - }, - }, nil + // Return a copy of the ServerDetail + serverDetailCopy := *entry + return &serverDetailCopy, nil } return nil, ErrNotFound @@ -153,24 +207,33 @@ func (db *MemoryDB) Publish(ctx context.Context, serverDetail *model.ServerDetai } // check that the name and the version are unique - + // Also check version ordering - don't allow publishing older versions after newer ones + var latestVersion string for _, entry := range db.entries { - if entry.Name == serverDetail.Name && entry.VersionDetail.Version == serverDetail.VersionDetail.Version { - return ErrAlreadyExists + if entry.Name == serverDetail.Name { + if entry.VersionDetail.Version == serverDetail.VersionDetail.Version { + return ErrAlreadyExists + } + + // Track the latest version for this package name + if latestVersion == "" || compareSemanticVersions(entry.VersionDetail.Version, latestVersion) > 0 { + latestVersion = entry.VersionDetail.Version + } } } + // If we found existing versions, check if the new version is older than the latest + if latestVersion != "" && compareSemanticVersions(serverDetail.VersionDetail.Version, latestVersion) < 0 { + return ErrInvalidVersion + } + if serverDetail.Repository.URL == "" { return ErrInvalidInput } - db.entries[serverDetail.ID] = &model.Server{ - ID: serverDetail.ID, - Name: serverDetail.Name, - Description: serverDetail.Description, - VersionDetail: serverDetail.VersionDetail, - Repository: serverDetail.Repository, - } + // Store a copy of the entire ServerDetail + serverDetailCopy := *serverDetail + db.entries[serverDetail.ID] = &serverDetailCopy return nil }