End-to-end publish test using examples from docs (#199)
This commit is contained in:
parent
774a0df592
commit
4f5e85b510
18 changed files with 1041 additions and 919 deletions
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
26
.github/workflows/integration-tests.yml
vendored
26
.github/workflows/integration-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
38
internal/auth/no_auth.go
Normal 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
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
//go:build !noauth
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
33
tests/integration/README.md
Normal file
33
tests/integration/README.md
Normal 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
|
||||
```
|
||||
13
tests/integration/docker-compose.integration-test.yml
Normal file
13
tests/integration/docker-compose.integration-test.yml
Normal 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
220
tests/integration/main.go
Normal 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
22
tests/integration/run.sh
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue