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
|
- name: Run integration tests
|
||||||
run: |
|
run: |
|
||||||
chmod +x ./integrationtests/run_tests.sh
|
chmod +x ./tests/integration/run.sh
|
||||||
./integrationtests/run_tests.sh
|
./tests/integration/run.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
|
|
||||||
|
|
||||||
# Overall status check
|
# Overall status check
|
||||||
test-summary:
|
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
|
- name: Run integration tests
|
||||||
run: |
|
run: |
|
||||||
# Run integration tests using the existing script
|
# Run integration tests using the existing script
|
||||||
chmod +x ./integrationtests/run_tests.sh
|
chmod +x ./tests/integration/run.sh
|
||||||
./integrationtests/run_tests.sh
|
./tests/integration/run.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
|
|
||||||
|
|
|
||||||
|
|
@ -117,10 +117,6 @@ issues:
|
||||||
- path: docs/
|
- path: docs/
|
||||||
linters:
|
linters:
|
||||||
- lll
|
- lll
|
||||||
# Ignore magic numbers in test files
|
|
||||||
- path: integrationtests/
|
|
||||||
linters:
|
|
||||||
- mnd
|
|
||||||
# Allow local replacement directives in go.mod
|
# Allow local replacement directives in go.mod
|
||||||
- path: go\.mod
|
- path: go\.mod
|
||||||
linters:
|
linters:
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ go run cmd/registry/main.go
|
||||||
go test -v -race -coverprofile=coverage.out -covermode=atomic ./internal/...
|
go test -v -race -coverprofile=coverage.out -covermode=atomic ./internal/...
|
||||||
|
|
||||||
# Integration tests
|
# Integration tests
|
||||||
./integrationtests/run_tests.sh
|
./tests/integration/run.sh
|
||||||
|
|
||||||
# Test API endpoints
|
# Test API endpoints
|
||||||
./scripts/test_endpoints.sh
|
./scripts/test_endpoints.sh
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.23-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
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
|
FROM alpine:latest
|
||||||
WORKDIR /app
|
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"
|
"id": "example-nuget-id-0000-1111-222222222222"
|
||||||
},
|
},
|
||||||
"version_detail": {
|
"version_detail": {
|
||||||
"version": "0.3.0-beta"
|
"version": "0.5.0"
|
||||||
},
|
},
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"registry_name": "nuget",
|
"registry_name": "nuget",
|
||||||
"name": "Knapcode.SampleMcpServer",
|
"name": "Knapcode.SampleMcpServer",
|
||||||
"version": "0.3.0-beta",
|
"version": "0.5.0",
|
||||||
"runtime_hint": "dnx",
|
"runtime_hint": "dnx",
|
||||||
"environment_variables": [
|
"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"
|
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
||||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||||
"github.com/modelcontextprotocol/registry/internal/model"
|
"github.com/modelcontextprotocol/registry/internal/model"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockRegistryService is a mock implementation of the RegistryService interface
|
// 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
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ func NewMongoDB(ctx context.Context, connectionURI, databaseName, collectionName
|
||||||
},
|
},
|
||||||
// add an index for the combination of name and version
|
// 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),
|
Options: options.Index().SetUnique(true),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +223,7 @@ func (db *MongoDB) Publish(ctx context.Context, serverDetail *model.ServerDetail
|
||||||
_, err = db.collection.UpdateOne(
|
_, err = db.collection.UpdateOne(
|
||||||
ctx,
|
ctx,
|
||||||
bson.M{"id": existingEntry.ID},
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("error updating existing entry: %w", err)
|
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"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -65,15 +66,18 @@ func main() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
command := os.Args[1]
|
var err error
|
||||||
switch command {
|
switch os.Args[1] {
|
||||||
case "publish":
|
case "publish":
|
||||||
publishCommand()
|
err = publishCommand()
|
||||||
case "create":
|
case "create":
|
||||||
createCommand()
|
err = createCommand()
|
||||||
default:
|
default:
|
||||||
printUsage()
|
printUsage()
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func printUsage() {
|
func printUsage() {
|
||||||
|
|
@ -86,7 +90,7 @@ func printUsage() {
|
||||||
fmt.Fprint(os.Stdout, "Use 'mcp-publisher <command> --help' for more information about a command.\n")
|
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)
|
publishFlags := flag.NewFlagSet("publish", flag.ExitOnError)
|
||||||
|
|
||||||
var registryURL string
|
var registryURL string
|
||||||
|
|
@ -119,14 +123,13 @@ func publishCommand() {
|
||||||
|
|
||||||
if registryURL == "" || mcpFilePath == "" {
|
if registryURL == "" || mcpFilePath == "" {
|
||||||
publishFlags.Usage()
|
publishFlags.Usage()
|
||||||
return
|
return errors.New("registry-url and mcp-file are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read MCP file
|
// Read MCP file
|
||||||
mcpData, err := os.ReadFile(mcpFilePath)
|
mcpData, err := os.ReadFile(mcpFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error reading MCP file: %s\n", err.Error())
|
return fmt.Errorf("error reading MCP file: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var authProvider auth.Provider // Determine the authentication method
|
var authProvider auth.Provider // Determine the authentication method
|
||||||
|
|
@ -135,8 +138,7 @@ func publishCommand() {
|
||||||
log.Println("Using GitHub OAuth for authentication")
|
log.Println("Using GitHub OAuth for authentication")
|
||||||
authProvider = github.NewOAuthProvider(forceLogin, registryURL)
|
authProvider = github.NewOAuthProvider(forceLogin, registryURL)
|
||||||
default:
|
default:
|
||||||
log.Printf("Unsupported authentication method: %s\n", authMethod)
|
return fmt.Errorf("unsupported authentication method: %s", authMethod)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if login is needed and perform authentication
|
// Check if login is needed and perform authentication
|
||||||
|
|
@ -144,29 +146,27 @@ func publishCommand() {
|
||||||
if authProvider.NeedsLogin() {
|
if authProvider.NeedsLogin() {
|
||||||
err := authProvider.Login(ctx)
|
err := authProvider.Login(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to authenticate with %s: %s\n", authProvider.Name(), err.Error())
|
return fmt.Errorf("failed to authenticate with %s: %w", authProvider.Name(), err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the token
|
// Get the token
|
||||||
token, err := authProvider.GetToken(ctx)
|
token, err := authProvider.GetToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error getting token from %s: %s\n", authProvider.Name(), err.Error())
|
return fmt.Errorf("error getting token from %s: %w", authProvider.Name(), err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish to registry
|
// Publish to registry
|
||||||
err = publishToRegistry(registryURL, mcpData, token)
|
err = publishToRegistry(registryURL, mcpData, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to publish to registry: %s\n", err.Error())
|
return fmt.Errorf("failed to publish to registry: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Successfully published to registry!")
|
log.Println("Successfully published to registry!")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createCommand() {
|
func createCommand() error {
|
||||||
createFlags := flag.NewFlagSet("create", flag.ExitOnError)
|
createFlags := flag.NewFlagSet("create", flag.ExitOnError)
|
||||||
|
|
||||||
// Basic server information flags
|
// Basic server information flags
|
||||||
|
|
@ -246,13 +246,13 @@ func createCommand() {
|
||||||
|
|
||||||
// Validate required flags
|
// Validate required flags
|
||||||
if name == "" {
|
if name == "" {
|
||||||
log.Fatal("Error: --name/-n is required")
|
return errors.New("--name/-n is required")
|
||||||
}
|
}
|
||||||
if description == "" {
|
if description == "" {
|
||||||
log.Fatal("Error: --description/-d is required")
|
return errors.New("--description/-d is required")
|
||||||
}
|
}
|
||||||
if repoURL == "" {
|
if repoURL == "" {
|
||||||
log.Fatal("Error: --repo-url is required")
|
return errors.New("--repo-url is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set defaults
|
// Set defaults
|
||||||
|
|
@ -270,13 +270,13 @@ func createCommand() {
|
||||||
// Convert to JSON
|
// Convert to JSON
|
||||||
jsonData, err := json.MarshalIndent(server, "", " ")
|
jsonData, err := json.MarshalIndent(server, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error marshaling JSON: %v", err)
|
return fmt.Errorf("error marshaling JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to file
|
// Write to file
|
||||||
err = os.WriteFile(output, jsonData, 0600)
|
err = os.WriteFile(output, jsonData, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error writing file: %v", err)
|
return fmt.Errorf("error writing file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Successfully created %s", output)
|
log.Printf("Successfully created %s", output)
|
||||||
|
|
@ -285,6 +285,7 @@ func createCommand() {
|
||||||
log.Println(" - Set environment variable requirements")
|
log.Println(" - Set environment variable requirements")
|
||||||
log.Println(" - Add remote server configurations")
|
log.Println(" - Add remote server configurations")
|
||||||
log.Println(" - Adjust runtime arguments")
|
log.Println(" - Adjust runtime arguments")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishToRegistry sends the MCP server details to the registry with authentication
|
// publishToRegistry sends the MCP server details to the registry with authentication
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue