End-to-end publish test using examples from docs (#199)

This commit is contained in:
Charles Lowell 2025-07-25 16:09:17 -07:00 • committed by GitHub
commit 4f5e85b510
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1041 additions and 919 deletions

View file

@ -141,20 +141,8 @@ jobs:
- name: Run integration tests
run: |
chmod +x ./integrationtests/run_tests.sh
./integrationtests/run_tests.sh
- name: Run integration tests with coverage
run: |
go test -v -race -coverprofile=integration-coverage.out -covermode=atomic ./integrationtests/...
- name: Upload integration coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./integration-coverage.out
flags: integrationtests
name: codecov-integration
fail_ci_if_error: false
chmod +x ./tests/integration/run.sh
./tests/integration/run.sh
# Overall status check
test-summary:

View file

@ -48,27 +48,5 @@ jobs:
- name: Run integration tests
run: |
# Run integration tests using the existing script
chmod +x ./integrationtests/run_tests.sh
./integrationtests/run_tests.sh
- name: Run integration tests with coverage
run: |
# Also run integration tests with Go test for coverage
go test -v -race -coverprofile=integration-coverage.out -covermode=atomic ./integrationtests/...
- name: Generate integration test coverage report
run: go tool cover -html=integration-coverage.out -o integration-coverage.html
- name: Upload integration coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./integration-coverage.out
flags: integrationtests
name: codecov-integration
fail_ci_if_error: false
- name: Upload integration coverage artifact
uses: actions/upload-artifact@v4
with:
name: integration-coverage-report
path: integration-coverage.html
chmod +x ./tests/integration/run.sh
./tests/integration/run.sh

View file

@ -117,10 +117,6 @@ issues:
- path: docs/
linters:
- lll
# Ignore magic numbers in test files
- path: integrationtests/
linters:
- mnd
# Allow local replacement directives in go.mod
- path: go\.mod
linters:

View file

@ -50,7 +50,7 @@ go run cmd/registry/main.go
go test -v -race -coverprofile=coverage.out -covermode=atomic ./internal/...
# Integration tests
./integrationtests/run_tests.sh
./tests/integration/run.sh
# Test API endpoints
./scripts/test_endpoints.sh

View file

@ -1,7 +1,8 @@
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o /build/registry ./cmd/registry
ARG GO_BUILD_TAGS
RUN go build ${GO_BUILD_TAGS:+-tags="$GO_BUILD_TAGS"} -o /build/registry ./cmd/registry
FROM alpine:latest
WORKDIR /app

View file

@ -222,13 +222,13 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
"id": "example-nuget-id-0000-1111-222222222222"
},
"version_detail": {
"version": "0.3.0-beta"
"version": "0.5.0"
},
"packages": [
{
"registry_name": "nuget",
"name": "Knapcode.SampleMcpServer",
"version": "0.3.0-beta",
"version": "0.5.0",
"runtime_hint": "dnx",
"environment_variables": [
{

View file

@ -1,98 +0,0 @@
# 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

@ -1,727 +0,0 @@
package integrationtests_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
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(
_ context.Context, _ model.AuthMethod, _ 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(_ 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(_ 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) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
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",
},
},
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(publishReq)
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.NotEmpty(t, response["id"], "Server ID should be generated")
// Verify the server was actually published by retrieving it
publishedServer, err := registryService.GetByID(response["id"])
require.NoError(t, err)
assert.Equal(t, publishReq.ServerDetail.Name, publishedServer.Name)
assert.Equal(t, publishReq.ServerDetail.Description, publishedServer.Description)
assert.Equal(t, publishReq.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) {
publishReq := &model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
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",
},
},
},
}
jsonData, err := json.Marshal(publishReq)
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.NotEmpty(t, response["id"], "Server ID should be generated")
})
t.Run("publish fails with missing name", func(t *testing.T) {
publishReq := &model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "", // Missing name
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
}
jsonData, err := json.Marshal(publishReq)
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{
Name: "test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "", // Missing version
},
},
}
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{
Name: "test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
}
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{
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",
},
},
}
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)
var response map[string]string
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, recorder.Code, "First publish should succeed")
firstServerDetail.ID = response["id"] // Store the ID for later verification
// Now try to publish another server with the same name and version
duplicateServerDetail := &model.ServerDetail{
Server: model.Server{
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
},
},
}
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)
})
t.Run("publish succeeds with same name but different version", func(t *testing.T) {
// Publish first version
firstVersionDetail := &model.ServerDetail{
Server: model.Server{
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",
},
},
}
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)
var response map[string]string
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
firstVersionDetail.ID = response["id"] // Store the ID for later verification
assert.Equal(t, http.StatusCreated, recorder.Code, "First version should succeed")
require.NotEmpty(t, firstVersionDetail.ID, "Server ID should be generated")
// Publish second version with same name but different version
secondVersionDetail := &model.ServerDetail{
Server: model.Server{
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
},
},
}
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)
var secondResponse map[string]string
err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondResponse)
require.NoError(t, err)
secondVersionDetail.ID = secondResponse["id"] // Store the ID for later verification
// The second version should succeed
assert.Equal(t, http.StatusCreated, secondRecorder.Code)
require.NotEmpty(t, secondVersionDetail.ID, "Server ID for second version should be generated")
// 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{
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",
},
},
}
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)
var response map[string]string
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
newerVersionDetail.ID = response["id"] // Store the ID for later verification
assert.Equal(t, http.StatusCreated, recorder.Code, "Newer version should be published successfully")
require.NotEmpty(t, newerVersionDetail.ID, "Server ID for newer version should be generated")
// Now try to publish an older version (1.0.0) of the same package
olderVersionDetail := &model.ServerDetail{
Server: model.Server{
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
},
},
}
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{
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",
},
},
Packages: []model.Package{
{
RegistryName: "npm",
Name: "@example/advanced-mcp-server",
Version: "43.1.0",
RunTimeHint: "node",
RuntimeArguments: []model.Argument{
{
Type: model.ArgumentTypeNamed,
Name: "experimental-modules",
},
{
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",
},
},
},
},
}
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)
serverDetail.ID = response["id"] // Store the ID for later verification
assert.Equal(t, "Server publication successful", response["message"])
assert.NotEmpty(t, response["id"], "Server ID should be generated")
// 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, "@example/advanced-mcp-server", pkg.Name)
assert.Len(t, pkg.RuntimeArguments, 3)
assert.Len(t, pkg.PackageArguments, 1)
assert.Len(t, pkg.EnvironmentVariables, 2)
// Verify remotes
require.Len(t, publishedServer.Remotes, 1)
assert.Equal(t, "http", publishedServer.Remotes[0].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{
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",
},
},
}
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)
var response map[string]string
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
serverDetail.ID = response["id"] // Store the ID for later verification
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")
})
}

View file

@ -1,24 +0,0 @@
#!/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

@ -11,8 +11,10 @@ import (
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/mock"
"github.com/stretchr/testify/require"
)
// MockRegistryService is a mock implementation of the RegistryService interface
@ -554,3 +556,680 @@ func TestPublishHandlerAuthMethodSelection(t *testing.T) {
})
}
}
func TestPublishIntegration(t *testing.T) {
// Setup fake service and auth service
registryService := service.NewFakeRegistryService()
authService := &MockAuthService{}
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
// Create the publish handler
handler := v0.PublishHandler(registryService, authService)
t.Run("successful publish with GitHub auth", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
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",
},
},
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(publishReq)
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.NotEmpty(t, response["id"], "Server ID should be generated")
// Verify the server was actually published by retrieving it
publishedServer, err := registryService.GetByID(response["id"])
require.NoError(t, err)
assert.Equal(t, publishReq.ServerDetail.Name, publishedServer.Name)
assert.Equal(t, publishReq.ServerDetail.Description, publishedServer.Description)
assert.Equal(t, publishReq.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) {
publishReq := &model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
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",
},
},
},
}
jsonData, err := json.Marshal(publishReq)
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.NotEmpty(t, response["id"], "Server ID should be generated")
})
t.Run("publish fails with missing name", func(t *testing.T) {
publishReq := &model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "", // Missing name
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
}
jsonData, err := json.Marshal(publishReq)
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{
Name: "test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "", // Missing version
},
},
}
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{
Name: "test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
}
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{
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",
},
},
}
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)
var response map[string]string
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, recorder.Code, "First publish should succeed")
firstServerDetail.ID = response["id"] // Store the ID for later verification
// Now try to publish another server with the same name and version
duplicateServerDetail := &model.ServerDetail{
Server: model.Server{
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
},
},
}
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)
})
t.Run("publish succeeds with same name but different version", func(t *testing.T) {
// Publish first version
firstVersionDetail := &model.ServerDetail{
Server: model.Server{
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",
},
},
}
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)
var response map[string]string
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
firstVersionDetail.ID = response["id"] // Store the ID for later verification
assert.Equal(t, http.StatusCreated, recorder.Code, "First version should succeed")
require.NotEmpty(t, firstVersionDetail.ID, "Server ID should be generated")
// Publish second version with same name but different version
secondVersionDetail := &model.ServerDetail{
Server: model.Server{
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
},
},
}
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)
var secondResponse map[string]string
err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondResponse)
require.NoError(t, err)
secondVersionDetail.ID = secondResponse["id"] // Store the ID for later verification
// The second version should succeed
assert.Equal(t, http.StatusCreated, secondRecorder.Code)
require.NotEmpty(t, secondVersionDetail.ID, "Server ID for second version should be generated")
// 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{
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",
},
},
}
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)
var response map[string]string
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
newerVersionDetail.ID = response["id"] // Store the ID for later verification
assert.Equal(t, http.StatusCreated, recorder.Code, "Newer version should be published successfully")
require.NotEmpty(t, newerVersionDetail.ID, "Server ID for newer version should be generated")
// Now try to publish an older version (1.0.0) of the same package
olderVersionDetail := &model.ServerDetail{
Server: model.Server{
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
},
},
}
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")
})
}
func TestPublishIntegrationEndToEnd(t *testing.T) {
registryService := service.NewFakeRegistryService()
authService := &MockAuthService{}
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
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{
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",
},
},
}
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)
var response map[string]string
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
serverDetail.ID = response["id"] // Store the ID for later verification
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")
})
}
func TestPublishIntegrationWithComplexPackages(t *testing.T) {
registryService := service.NewFakeRegistryService()
authService := &MockAuthService{}
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
handler := v0.PublishHandler(registryService, authService)
t.Run("publish with complex package configuration", func(t *testing.T) {
serverDetail := &model.ServerDetail{
Server: model.Server{
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",
},
},
Packages: []model.Package{
{
RegistryName: "npm",
Name: "@example/advanced-mcp-server",
Version: "43.1.0",
RunTimeHint: "node",
RuntimeArguments: []model.Argument{
{
Type: model.ArgumentTypeNamed,
Name: "experimental-modules",
},
{
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",
},
},
},
},
}
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)
serverDetail.ID = response["id"] // Store the ID for later verification
assert.Equal(t, "Server publication successful", response["message"])
assert.NotEmpty(t, response["id"], "Server ID should be generated")
// 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, "@example/advanced-mcp-server", pkg.Name)
assert.Len(t, pkg.RuntimeArguments, 3)
assert.Len(t, pkg.PackageArguments, 1)
assert.Len(t, pkg.EnvironmentVariables, 2)
// Verify remotes
require.Len(t, publishedServer.Remotes, 1)
assert.Equal(t, "http", publishedServer.Remotes[0].TransportType)
assert.Len(t, publishedServer.Remotes[0].Headers, 1)
})
}

38
internal/auth/no_auth.go Normal file
View file

@ -0,0 +1,38 @@
//go:build noauth
package auth
import (
"context"
"fmt"
"os"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/model"
)
func init() {
fmt.Fprintln(os.Stderr, `WARNING: "noauth" build tag has disabled authentication`)
}
// NewAuthService creates a new authentication service that does nothing. All its methods succeed unconditionally.
func NewAuthService(*config.Config) Service {
return &noAuth{}
}
type noAuth struct{}
// StartAuthFlow always returns fake flow info and a fake status token
func (*noAuth) StartAuthFlow(context.Context, model.AuthMethod, string) (map[string]string, string, error) {
return map[string]string{"fake": "info"}, "fake-status-token", nil
}
// CheckAuthStatus always returns a fake token
func (*noAuth) CheckAuthStatus(context.Context, string) (string, error) {
return "fake-token", nil
}
// ValidateAuth always returns true
func (*noAuth) ValidateAuth(context.Context, model.Authentication) (bool, error) {
return true, nil
}

View file

@ -1,3 +1,5 @@
//go:build !noauth
package auth
import (

View file

@ -50,7 +50,7 @@ func NewMongoDB(ctx context.Context, connectionURI, databaseName, collectionName
},
// add an index for the combination of name and version
{
Keys: bson.D{bson.E{Key: "name", Value: 1}, bson.E{Key: "versiondetail.version", Value: 1}},
Keys: bson.D{bson.E{Key: "name", Value: 1}, bson.E{Key: "version_detail.version", Value: 1}},
Options: options.Index().SetUnique(true),
},
}
@ -223,7 +223,7 @@ func (db *MongoDB) Publish(ctx context.Context, serverDetail *model.ServerDetail
_, err = db.collection.UpdateOne(
ctx,
bson.M{"id": existingEntry.ID},
bson.M{"$set": bson.M{"versiondetail.islatest": false}})
bson.M{"$set": bson.M{"version_detail.islatest": false}})
if err != nil {
return fmt.Errorf("error updating existing entry: %w", err)
}

View file

@ -0,0 +1,33 @@
# Integration Test
This directory contains an end-to-end test for publishing to the registry.
## What the Test Covers
1. **Publisher Tool**: Tests the `publisher` CLI that publishes metadata to the registry
2. **Registry API**: Validates the `/v0/publish` and `/v0/servers/{id}` endpoints work correctly
3. **Example Validation**: Ensures all example JSON in `docs/server-json/examples.md` is valid and can be published
4. **Data Consistency**: Verifies published data matches what's retrieved from the registry
## Test Flow
1. **Build**: Build `publisher` and `registry`
- build `registry` with the `noauth` tag so that it accepts fake tokens
2. **Start Services**: Launch registry and MongoDB using Docker Compose with test configuration
3. **Publish Examples**: Extract JSON examples from documentation and run `publisher` to publish each one
4. **Validate Responses**: GET each published server from the registry and compare it to the example JSON
5. **Cleanup**: Stop Docker containers and remove temporary files
## How to Run
### Prerequisites
- Docker and Docker Compose
- Go 1.23
- Make sure you're in the repository root directory
### Run the Tests
```sh
./tests/integration/run.sh
```

View file

@ -0,0 +1,13 @@
services:
registry:
environment:
- MCP_REGISTRY_SEED_IMPORT=false
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/v0/servers"]
interval: 1s
retries: 60
timeout: 1s
mongodb:
volumes: !reset []
tmpfs:
- /data/db:size=50m

220
tests/integration/main.go Normal file
View file

@ -0,0 +1,220 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"reflect"
"regexp"
"strings"
"time"
)
const registryURL = "http://localhost:8080"
var publishedIDRegex = regexp.MustCompile(`"id":\s*"([^"]+)"`)
func main() {
log.SetFlags(0)
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
examplesPath := filepath.Join("docs", "server-json", "examples.md")
examples, err := examples(examplesPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
log.Fatalf("%q not found; run this test from the repo root", examplesPath)
}
log.Fatalf("failed to extract examples from %q: %v", examplesPath, err)
}
log.Printf("Found %d examples in %q\n", len(examples), examplesPath)
// publisher will send this fake token to the registry, which will accept it
// because the registry was built with noauth enabled (see tests/integration/run.sh)
if err := os.WriteFile(".mcpregistry_token", []byte("fake"), 0600); err != nil {
log.Fatalf("failed to write fake token: %v", err)
}
defer os.Remove(".mcpregistry_token")
return publish(examples)
}
func publish(examples []example) error {
published := 0
for _, example := range examples {
log.Printf("Publishing example starting on line %d", example.line)
expected := map[string]any{}
if err := json.Unmarshal(example.content, &expected); err != nil {
log.Println(" ⛔ Example isn't valid JSON:", err)
continue
}
p := filepath.Join("bin", fmt.Sprintf("example-line-%d.json", example.line))
if err := os.WriteFile(p, example.content, 0600); err != nil {
log.Printf(" ⛔ Failed to write example JSON to %s: %v\n", p, err)
continue
}
defer os.Remove(p)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "./bin/publisher", "publish", "--mcp-file", p, "--registry-url", registryURL)
cmd.WaitDelay = 100 * time.Millisecond
out, err := cmd.CombinedOutput()
if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) {
return errors.New(" ⛔ publisher not found; did you run tests/integration/run.sh?")
}
output := strings.TrimSpace("publisher output:\n\t" + strings.ReplaceAll(string(out), "\n", "\n \t"))
if err != nil {
return errors.New(" ⛔ " + output)
}
log.Println(" ✅", output)
m := publishedIDRegex.FindStringSubmatch(output)
if len(m) != 2 || m[1] == "" {
return errors.New(" ⛔ Didn't find ID in publisher output")
}
id := m[1]
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryURL+"/v0/servers/"+id, nil)
if err != nil {
return fmt.Errorf(" ⛔ %w", err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf(" ⛔ %w", err)
}
content, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf(" ⛔ failed to read registry response: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf(" ⛔ registry responded %d: %s", res.StatusCode, string(content))
}
actual := map[string]any{}
if err := json.Unmarshal(content, &actual); err != nil {
return fmt.Errorf(" ⛔ failed to unmarshal registry response: %w", err)
}
for k, v := range expected {
v2 := actual[k]
if err := compare(v, v2); err != nil {
return fmt.Errorf(` ⛔ field "%s": %w`, k, err)
}
}
log.Print(" ✅ registry response matches example\n\n")
published++
}
msg := fmt.Sprintf("published %d/%d examples", published, len(examples))
if published != len(examples) {
return errors.New(msg)
}
log.Println(msg)
return nil
}
type example struct {
content []byte
line int
}
func examples(path string) ([]example, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
// matches JSON code blocks in markdown,
// captures everything between ```json and ```
re := regexp.MustCompile("(?s)```json\n(.*?)\n```")
matches := re.FindAllSubmatchIndex(b, -1)
examples := make([]example, len(matches))
for i, match := range matches {
if len(match) < 4 {
// should never happen
return nil, fmt.Errorf("invalid match: expected 4 indices but got %d", len(match))
}
start, end := match[2], match[3]
// line numbers start at 1
line := 1 + bytes.Count(b[:start], []byte{'\n'})
examples[i] = example{
content: b[start:end],
line: line,
}
}
return examples, nil
}
// compare performs a deep comparison of JSON values. It returns an error when an expected value
// isn't in actual, unless the expected value is the zero value for its type. This exception
// is necessary because registry model fields are typically tagged "omitempty". A field having a
// zero value may therefore not be present in a registry response. compare doesn't consider whether
// actual has additional fields not in expected; it only checks that all expected fields are present.
func compare(expected, actual any) error {
if reflect.ValueOf(expected).IsZero() {
return nil
}
if actual == nil {
return fmt.Errorf("expected %v, got nil", expected)
}
switch expectedValue := expected.(type) {
case map[string]any:
actualMap, ok := actual.(map[string]any)
if !ok {
return fmt.Errorf("expected map, got %T", actual)
}
for k, v := range expectedValue {
// note key may not be present in actualMap, if the value would be zero
if actualValue, ok := actualMap[k]; ok {
if err := compare(v, actualValue); err != nil {
return fmt.Errorf("key %q: %w", k, err)
}
}
}
return nil
case []any:
actualSlice, ok := actual.([]any)
if !ok {
return fmt.Errorf("expected array, got %T", actual)
}
for _, expectedItem := range expectedValue {
found := false
for _, actualItem := range actualSlice {
if err := compare(expectedItem, actualItem); err == nil {
found = true
break
}
}
if !found {
return fmt.Errorf("%v missing in actual array", expectedItem)
}
}
return nil
default:
if expected != actual {
return fmt.Errorf("expected %v, got %v", expected, actual)
}
return nil
}
}

22
tests/integration/run.sh Executable file
View file

@ -0,0 +1,22 @@
#!/bin/bash
set -e
cleanup() {
echo "========== registry logs =========="
docker logs registry || true
echo "=========== cleaning up ==========="
docker compose down -v
}
go build -o ./bin/publisher ./tools/publisher
docker build -t registry --build-arg GO_BUILD_TAGS=noauth .
trap cleanup EXIT
export MCP_REGISTRY_GITHUB_CLIENT_ID=fake
export MCP_REGISTRY_GITHUB_CLIENT_SECRET=fake
docker compose -f docker-compose.yml -f tests/integration/docker-compose.integration-test.yml up --wait --wait-timeout 60
go run tests/integration/main.go

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
@ -65,15 +66,18 @@ func main() {
return
}
command := os.Args[1]
switch command {
var err error
switch os.Args[1] {
case "publish":
publishCommand()
err = publishCommand()
case "create":
createCommand()
err = createCommand()
default:
printUsage()
}
if err != nil {
log.Fatal(err)
}
}
func printUsage() {
@ -86,7 +90,7 @@ func printUsage() {
fmt.Fprint(os.Stdout, "Use 'mcp-publisher <command> --help' for more information about a command.\n")
}
func publishCommand() {
func publishCommand() error {
publishFlags := flag.NewFlagSet("publish", flag.ExitOnError)
var registryURL string
@ -119,14 +123,13 @@ func publishCommand() {
if registryURL == "" || mcpFilePath == "" {
publishFlags.Usage()
return
return errors.New("registry-url and mcp-file are required")
}
// Read MCP file
mcpData, err := os.ReadFile(mcpFilePath)
if err != nil {
log.Printf("Error reading MCP file: %s\n", err.Error())
return
return fmt.Errorf("error reading MCP file: %w", err)
}
var authProvider auth.Provider // Determine the authentication method
@ -135,8 +138,7 @@ func publishCommand() {
log.Println("Using GitHub OAuth for authentication")
authProvider = github.NewOAuthProvider(forceLogin, registryURL)
default:
log.Printf("Unsupported authentication method: %s\n", authMethod)
return
return fmt.Errorf("unsupported authentication method: %s", authMethod)
}
// Check if login is needed and perform authentication
@ -144,29 +146,27 @@ func publishCommand() {
if authProvider.NeedsLogin() {
err := authProvider.Login(ctx)
if err != nil {
log.Printf("Failed to authenticate with %s: %s\n", authProvider.Name(), err.Error())
return
return fmt.Errorf("failed to authenticate with %s: %w", authProvider.Name(), err)
}
}
// Get the token
token, err := authProvider.GetToken(ctx)
if err != nil {
log.Printf("Error getting token from %s: %s\n", authProvider.Name(), err.Error())
return
return fmt.Errorf("error getting token from %s: %w", authProvider.Name(), err)
}
// Publish to registry
err = publishToRegistry(registryURL, mcpData, token)
if err != nil {
log.Printf("Failed to publish to registry: %s\n", err.Error())
return
return fmt.Errorf("failed to publish to registry: %w", err)
}
log.Println("Successfully published to registry!")
return nil
}
func createCommand() {
func createCommand() error {
createFlags := flag.NewFlagSet("create", flag.ExitOnError)
// Basic server information flags
@ -246,13 +246,13 @@ func createCommand() {
// Validate required flags
if name == "" {
log.Fatal("Error: --name/-n is required")
return errors.New("--name/-n is required")
}
if description == "" {
log.Fatal("Error: --description/-d is required")
return errors.New("--description/-d is required")
}
if repoURL == "" {
log.Fatal("Error: --repo-url is required")
return errors.New("--repo-url is required")
}
// Set defaults
@ -270,13 +270,13 @@ func createCommand() {
// Convert to JSON
jsonData, err := json.MarshalIndent(server, "", " ")
if err != nil {
log.Fatalf("Error marshaling JSON: %v", err)
return fmt.Errorf("error marshaling JSON: %w", err)
}
// Write to file
err = os.WriteFile(output, jsonData, 0600)
if err != nil {
log.Fatalf("Error writing file: %v", err)
return fmt.Errorf("error writing file: %w", err)
}
log.Printf("Successfully created %s", output)
@ -285,6 +285,7 @@ func createCommand() {
log.Println(" - Set environment variable requirements")
log.Println(" - Add remote server configurations")
log.Println(" - Adjust runtime arguments")
return nil
}
// publishToRegistry sends the MCP server details to the registry with authentication