feat(tests): add integration tests for publish functionality and enhance error handling
This commit is contained in:
parent
d266dd4eb0
commit
d0a0adf620
6 changed files with 956 additions and 27 deletions
98
integrationtests/README.md
Normal file
98
integrationtests/README.md
Normal 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
|
||||
736
integrationtests/publish_integration_test.go
Normal file
736
integrationtests/publish_integration_test.go
Normal 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
24
integrationtests/run_tests.sh
Executable 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue