feat(tests): add integration tests for publish functionality and enhance error handling

This commit is contained in:
Avinash Sridhar 2025-05-26 12:00:25 -04:00 • committed by Toby Padilla
commit d0a0adf620
6 changed files with 956 additions and 27 deletions

View file

@ -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

View file

@ -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")
})
}

24
integrationtests/run_tests.sh Executable file
View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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
}