Database implementation cleanup (#335)
## Motivation and Context The database logic was getting a bit unwieldy and fragile - especially because business logic that should really have lived at the registry service level was being pushed into the persistence level. This PR tightens the interface, simplifies the code there, as well as rips out a bunch of mocks etc. we don't need. ## How Has This Been Tested? Locally everything works ## Breaking Changes The list endpoint was surprisingly always filtering for isLatest only, which seems like a bug? I have removed this filter.
This commit is contained in:
parent
07d353ba92
commit
9d9a02c57f
19 changed files with 669 additions and 1280 deletions
2
Makefile
2
Makefile
|
|
@ -14,7 +14,7 @@ publisher: ## Build the publisher tool
|
||||||
|
|
||||||
# Test targets
|
# Test targets
|
||||||
test-unit: ## Run unit tests with coverage
|
test-unit: ## Run unit tests with coverage
|
||||||
go test -v -race -coverprofile=coverage.out -covermode=atomic ./internal/...
|
go test -v -race -coverprofile=coverage.out -covermode=atomic -coverpkg=./internal/... ./internal/...
|
||||||
go tool cover -html=coverage.out -o coverage.html
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
@echo "Coverage report generated: coverage.html"
|
@echo "Coverage report generated: coverage.html"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ import (
|
||||||
"github.com/modelcontextprotocol/registry/internal/api"
|
"github.com/modelcontextprotocol/registry/internal/api"
|
||||||
"github.com/modelcontextprotocol/registry/internal/config"
|
"github.com/modelcontextprotocol/registry/internal/config"
|
||||||
"github.com/modelcontextprotocol/registry/internal/database"
|
"github.com/modelcontextprotocol/registry/internal/database"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/importer"
|
||||||
"github.com/modelcontextprotocol/registry/internal/service"
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
"github.com/modelcontextprotocol/registry/internal/telemetry"
|
"github.com/modelcontextprotocol/registry/internal/telemetry"
|
||||||
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -46,7 +46,7 @@ func main() {
|
||||||
// Initialize services based on environment
|
// Initialize services based on environment
|
||||||
switch cfg.DatabaseType {
|
switch cfg.DatabaseType {
|
||||||
case config.DatabaseTypeMemory:
|
case config.DatabaseTypeMemory:
|
||||||
db = database.NewMemoryDB(map[string]*apiv0.ServerJSON{})
|
db = database.NewMemoryDB()
|
||||||
registryService = service.NewRegistryServiceWithDB(db)
|
registryService = service.NewRegistryServiceWithDB(db)
|
||||||
case config.DatabaseTypePostgreSQL:
|
case config.DatabaseTypePostgreSQL:
|
||||||
// Use PostgreSQL for real registry service
|
// Use PostgreSQL for real registry service
|
||||||
|
|
@ -84,7 +84,8 @@ func main() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := db.ImportSeed(ctx, cfg.SeedFrom); err != nil {
|
importerService := importer.NewService(db)
|
||||||
|
if err := importerService.ImportFromPath(ctx, cfg.SeedFrom); err != nil {
|
||||||
log.Printf("Failed to import seed data: %v", err)
|
log.Printf("Failed to import seed data: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Println("Data import completed successfully")
|
log.Println("Data import completed successfully")
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -38,7 +38,6 @@ require (
|
||||||
github.com/prometheus/common v0.65.0 // indirect
|
github.com/prometheus/common v0.65.0 // indirect
|
||||||
github.com/prometheus/otlptranslator v0.0.0-20250717125610-8549f4ab4f8f // indirect
|
github.com/prometheus/otlptranslator v0.0.0-20250717125610-8549f4ab4f8f // indirect
|
||||||
github.com/prometheus/procfs v0.17.0 // indirect
|
github.com/prometheus/procfs v0.17.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||||
golang.org/x/crypto v0.41.0 // indirect
|
golang.org/x/crypto v0.41.0 // indirect
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package v0_test
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -11,29 +10,96 @@ import (
|
||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
|
|
||||||
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/config"
|
"github.com/modelcontextprotocol/registry/internal/config"
|
||||||
"github.com/modelcontextprotocol/registry/internal/database"
|
"github.com/modelcontextprotocol/registry/internal/database"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
||||||
"github.com/modelcontextprotocol/registry/pkg/model"
|
"github.com/modelcontextprotocol/registry/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEditServerEndpoint(t *testing.T) {
|
func TestEditServerEndpoint(t *testing.T) {
|
||||||
|
// Create registry service and insert a common test server
|
||||||
|
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
|
||||||
|
|
||||||
|
// Publish a test server that will be used across test cases
|
||||||
|
testServer := apiv0.ServerJSON{
|
||||||
|
Name: "io.github.domdomegg/test-server",
|
||||||
|
Description: "Original test server",
|
||||||
|
Status: model.StatusActive,
|
||||||
|
Repository: model.Repository{
|
||||||
|
URL: "https://github.com/domdomegg/test-server",
|
||||||
|
Source: "github",
|
||||||
|
ID: "domdomegg/test-server",
|
||||||
|
},
|
||||||
|
VersionDetail: model.VersionDetail{
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
published, err := registryService.Publish(testServer)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, published)
|
||||||
|
assert.NotNil(t, published.Meta)
|
||||||
|
assert.NotNil(t, published.Meta.IOModelContextProtocolRegistry)
|
||||||
|
|
||||||
|
testServerID := published.Meta.IOModelContextProtocolRegistry.ID
|
||||||
|
|
||||||
|
// Publish a second server for permission testing
|
||||||
|
otherServer := apiv0.ServerJSON{
|
||||||
|
Name: "io.github.other/test-server",
|
||||||
|
Description: "Other test server",
|
||||||
|
Status: model.StatusActive,
|
||||||
|
Repository: model.Repository{
|
||||||
|
URL: "https://github.com/other/test-server",
|
||||||
|
Source: "github",
|
||||||
|
ID: "other/test-server",
|
||||||
|
},
|
||||||
|
VersionDetail: model.VersionDetail{
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
otherPublished, err := registryService.Publish(otherServer)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, otherPublished)
|
||||||
|
assert.NotNil(t, otherPublished.Meta)
|
||||||
|
assert.NotNil(t, otherPublished.Meta.IOModelContextProtocolRegistry)
|
||||||
|
|
||||||
|
otherServerID := otherPublished.Meta.IOModelContextProtocolRegistry.ID
|
||||||
|
|
||||||
|
// Publish a deleted server for undelete testing
|
||||||
|
deletedServer := apiv0.ServerJSON{
|
||||||
|
Name: "io.github.domdomegg/deleted-server",
|
||||||
|
Description: "Deleted test server",
|
||||||
|
Status: model.StatusDeleted,
|
||||||
|
Repository: model.Repository{
|
||||||
|
URL: "https://github.com/domdomegg/deleted-server",
|
||||||
|
Source: "github",
|
||||||
|
ID: "domdomegg/deleted-server",
|
||||||
|
},
|
||||||
|
VersionDetail: model.VersionDetail{
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
deletedPublished, err := registryService.Publish(deletedServer)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, deletedPublished)
|
||||||
|
assert.NotNil(t, deletedPublished.Meta)
|
||||||
|
assert.NotNil(t, deletedPublished.Meta.IOModelContextProtocolRegistry)
|
||||||
|
|
||||||
|
deletedServerID := deletedPublished.Meta.IOModelContextProtocolRegistry.ID
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
serverID string
|
|
||||||
authHeader string
|
authHeader string
|
||||||
requestBody interface{}
|
requestBody interface{}
|
||||||
setupMocks func(*MockRegistryService)
|
serverID string
|
||||||
expectedStatus int
|
expectedStatus int
|
||||||
expectedError string
|
expectedError string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "successful edit with valid token and permissions",
|
name: "successful edit with valid token and permissions",
|
||||||
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
authHeader: func() string {
|
authHeader: func() string {
|
||||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
|
@ -58,62 +124,43 @@ func TestEditServerEndpoint(t *testing.T) {
|
||||||
Version: "1.0.1",
|
Version: "1.0.1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
serverID: testServerID,
|
||||||
// Current server (not deleted)
|
|
||||||
currentServer := &apiv0.ServerJSON{
|
|
||||||
Name: "io.github.domdomegg/test-server",
|
|
||||||
Description: "Original server",
|
|
||||||
Status: model.StatusActive,
|
|
||||||
}
|
|
||||||
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
|
|
||||||
|
|
||||||
expectedResponse := &apiv0.ServerJSON{
|
|
||||||
Name: "io.github.domdomegg/test-server",
|
|
||||||
Description: "Updated test server",
|
|
||||||
Status: model.StatusDeprecated,
|
|
||||||
}
|
|
||||||
registry.On("EditServer", "550e8400-e29b-41d4-a716-446655440001", mock.AnythingOfType("v0.ServerJSON")).Return(expectedResponse, nil)
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
expectedStatus: http.StatusOK,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing authorization header",
|
name: "missing authorization header",
|
||||||
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
authHeader: "",
|
authHeader: "",
|
||||||
requestBody: apiv0.ServerJSON{},
|
requestBody: apiv0.ServerJSON{},
|
||||||
setupMocks: func(_ *MockRegistryService) {},
|
serverID: testServerID,
|
||||||
expectedStatus: 422,
|
expectedStatus: 422,
|
||||||
expectedError: "required header parameter is missing",
|
expectedError: "required header parameter is missing",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid authorization header format",
|
name: "invalid authorization header format",
|
||||||
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
authHeader: "InvalidFormat token123",
|
authHeader: "InvalidFormat token123",
|
||||||
requestBody: apiv0.ServerJSON{
|
requestBody: apiv0.ServerJSON{
|
||||||
Name: "io.github.domdomegg/test-server",
|
Name: "io.github.domdomegg/test-server",
|
||||||
Description: "Test server",
|
Description: "Test server",
|
||||||
VersionDetail: model.VersionDetail{Version: "1.0.0"},
|
VersionDetail: model.VersionDetail{Version: "1.0.0"},
|
||||||
},
|
},
|
||||||
setupMocks: func(_ *MockRegistryService) {},
|
serverID: testServerID,
|
||||||
expectedStatus: http.StatusUnauthorized,
|
expectedStatus: http.StatusUnauthorized,
|
||||||
expectedError: "Unauthorized",
|
expectedError: "Unauthorized",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid token",
|
name: "invalid token",
|
||||||
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
authHeader: "Bearer invalid-token",
|
authHeader: "Bearer invalid-token",
|
||||||
requestBody: apiv0.ServerJSON{
|
requestBody: apiv0.ServerJSON{
|
||||||
Name: "io.github.domdomegg/test-server",
|
Name: "io.github.domdomegg/test-server",
|
||||||
Description: "Test server",
|
Description: "Test server",
|
||||||
VersionDetail: model.VersionDetail{Version: "1.0.0"},
|
VersionDetail: model.VersionDetail{Version: "1.0.0"},
|
||||||
},
|
},
|
||||||
setupMocks: func(_ *MockRegistryService) {},
|
serverID: testServerID,
|
||||||
expectedStatus: http.StatusUnauthorized,
|
expectedStatus: http.StatusUnauthorized,
|
||||||
expectedError: "Unauthorized",
|
expectedError: "Unauthorized",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "permission denied - no edit permissions",
|
name: "permission denied - no edit permissions",
|
||||||
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
authHeader: func() string {
|
authHeader: func() string {
|
||||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
|
@ -126,24 +173,16 @@ func TestEditServerEndpoint(t *testing.T) {
|
||||||
return "Bearer " + token
|
return "Bearer " + token
|
||||||
}(),
|
}(),
|
||||||
requestBody: apiv0.ServerJSON{
|
requestBody: apiv0.ServerJSON{
|
||||||
Name: "io.github.domdomegg/test-server",
|
Name: "io.github.domdomegg/test-server",
|
||||||
Description: "Updated test server",
|
Description: "Updated test server",
|
||||||
},
|
VersionDetail: model.VersionDetail{Version: "1.0.0"},
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
|
||||||
// Need to mock GetByID since we check permissions against existing server name
|
|
||||||
currentServer := &apiv0.ServerJSON{
|
|
||||||
Name: "io.github.domdomegg/test-server",
|
|
||||||
Description: "Original server",
|
|
||||||
Status: model.StatusActive,
|
|
||||||
}
|
|
||||||
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
|
|
||||||
},
|
},
|
||||||
|
serverID: testServerID,
|
||||||
expectedStatus: http.StatusForbidden,
|
expectedStatus: http.StatusForbidden,
|
||||||
expectedError: "Forbidden",
|
expectedError: "Forbidden",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "permission denied - wrong resource",
|
name: "permission denied - wrong resource",
|
||||||
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
authHeader: func() string {
|
authHeader: func() string {
|
||||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
|
@ -156,25 +195,16 @@ func TestEditServerEndpoint(t *testing.T) {
|
||||||
return "Bearer " + token
|
return "Bearer " + token
|
||||||
}(),
|
}(),
|
||||||
requestBody: apiv0.ServerJSON{
|
requestBody: apiv0.ServerJSON{
|
||||||
Name: "io.github.other/test-server",
|
Name: "io.github.other/test-server",
|
||||||
Description: "Updated test server",
|
Description: "Updated test server",
|
||||||
},
|
VersionDetail: model.VersionDetail{Version: "1.0.0"},
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
|
||||||
// Need to mock GetByID since we check permissions against existing server name
|
|
||||||
// This test case shows a different scenario: existing server is "other" but user only has perms for "domdomegg"
|
|
||||||
currentServer := &apiv0.ServerJSON{
|
|
||||||
Name: "io.github.other/test-server",
|
|
||||||
Description: "Original server",
|
|
||||||
Status: model.StatusActive,
|
|
||||||
}
|
|
||||||
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
|
|
||||||
},
|
},
|
||||||
|
serverID: otherServerID,
|
||||||
expectedStatus: http.StatusForbidden,
|
expectedStatus: http.StatusForbidden,
|
||||||
expectedError: "Forbidden",
|
expectedError: "Forbidden",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "server not found",
|
name: "server not found",
|
||||||
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
authHeader: func() string {
|
authHeader: func() string {
|
||||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
|
@ -187,49 +217,16 @@ func TestEditServerEndpoint(t *testing.T) {
|
||||||
return "Bearer " + token
|
return "Bearer " + token
|
||||||
}(),
|
}(),
|
||||||
requestBody: apiv0.ServerJSON{
|
requestBody: apiv0.ServerJSON{
|
||||||
Name: "io.github.domdomegg/test-server",
|
Name: "io.github.domdomegg/nonexistent-server",
|
||||||
Description: "Updated test server",
|
Description: "Updated test server",
|
||||||
},
|
VersionDetail: model.VersionDetail{Version: "1.0.0"},
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
|
||||||
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(nil, database.ErrNotFound)
|
|
||||||
},
|
},
|
||||||
|
serverID: "550e8400-e29b-41d4-a716-446655440999", // Non-existent ID
|
||||||
expectedStatus: http.StatusNotFound,
|
expectedStatus: http.StatusNotFound,
|
||||||
expectedError: "Not Found",
|
expectedError: "Not Found",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "service error",
|
|
||||||
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
authHeader: func() string {
|
|
||||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
|
||||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
|
||||||
AuthMethod: auth.MethodGitHubAT,
|
|
||||||
AuthMethodSubject: "domdomegg",
|
|
||||||
Permissions: []auth.Permission{
|
|
||||||
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return "Bearer " + token
|
|
||||||
}(),
|
|
||||||
requestBody: apiv0.ServerJSON{
|
|
||||||
Name: "io.github.domdomegg/test-server",
|
|
||||||
Description: "Updated test server",
|
|
||||||
},
|
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
|
||||||
// Current server (not deleted)
|
|
||||||
currentServer := &apiv0.ServerJSON{
|
|
||||||
Name: "io.github.domdomegg/test-server",
|
|
||||||
Description: "Original server",
|
|
||||||
Status: model.StatusActive,
|
|
||||||
}
|
|
||||||
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
|
|
||||||
registry.On("EditServer", "550e8400-e29b-41d4-a716-446655440001", mock.AnythingOfType("v0.ServerJSON")).Return(nil, errors.New("database error"))
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusBadRequest,
|
|
||||||
expectedError: "Bad Request",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "validation error - invalid server name",
|
name: "validation error - invalid server name",
|
||||||
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
authHeader: func() string {
|
authHeader: func() string {
|
||||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
|
@ -246,20 +243,12 @@ func TestEditServerEndpoint(t *testing.T) {
|
||||||
Description: "Test server",
|
Description: "Test server",
|
||||||
VersionDetail: model.VersionDetail{Version: "1.0.0"},
|
VersionDetail: model.VersionDetail{Version: "1.0.0"},
|
||||||
},
|
},
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
serverID: testServerID,
|
||||||
currentServer := &apiv0.ServerJSON{
|
|
||||||
Name: "io.github.domdomegg/test-server",
|
|
||||||
Description: "Original server",
|
|
||||||
Status: model.StatusActive,
|
|
||||||
}
|
|
||||||
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusBadRequest,
|
expectedStatus: http.StatusBadRequest,
|
||||||
expectedError: "Bad Request",
|
expectedError: "Bad Request",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cannot undelete server",
|
name: "cannot undelete server",
|
||||||
serverID: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
authHeader: func() string {
|
authHeader: func() string {
|
||||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||||
|
|
@ -272,19 +261,19 @@ func TestEditServerEndpoint(t *testing.T) {
|
||||||
return "Bearer " + token
|
return "Bearer " + token
|
||||||
}(),
|
}(),
|
||||||
requestBody: apiv0.ServerJSON{
|
requestBody: apiv0.ServerJSON{
|
||||||
Name: "io.github.domdomegg/test-server",
|
Name: "io.github.domdomegg/deleted-server",
|
||||||
Description: "Trying to undelete server",
|
Description: "Trying to undelete server",
|
||||||
Status: model.StatusActive,
|
Status: model.StatusActive,
|
||||||
|
Repository: model.Repository{
|
||||||
|
URL: "https://github.com/domdomegg/deleted-server",
|
||||||
|
Source: "github",
|
||||||
|
ID: "domdomegg/deleted-server",
|
||||||
|
},
|
||||||
|
VersionDetail: model.VersionDetail{
|
||||||
|
Version: "1.0.1",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
serverID: deletedServerID,
|
||||||
// Current server is deleted
|
|
||||||
currentServer := &apiv0.ServerJSON{
|
|
||||||
Name: "io.github.domdomegg/test-server",
|
|
||||||
Description: "Original server",
|
|
||||||
Status: model.StatusDeleted,
|
|
||||||
}
|
|
||||||
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusBadRequest,
|
expectedStatus: http.StatusBadRequest,
|
||||||
expectedError: "Cannot change status of deleted server",
|
expectedError: "Cannot change status of deleted server",
|
||||||
},
|
},
|
||||||
|
|
@ -292,10 +281,6 @@ func TestEditServerEndpoint(t *testing.T) {
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
// Create mock registry
|
|
||||||
mockRegistry := new(MockRegistryService)
|
|
||||||
tc.setupMocks(mockRegistry)
|
|
||||||
|
|
||||||
// Create Huma API
|
// Create Huma API
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
humaConfig := huma.DefaultConfig("Test API", "1.0.0")
|
humaConfig := huma.DefaultConfig("Test API", "1.0.0")
|
||||||
|
|
@ -305,7 +290,7 @@ func TestEditServerEndpoint(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c",
|
JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c",
|
||||||
}
|
}
|
||||||
v0.RegisterEditEndpoints(api, mockRegistry, cfg)
|
v0.RegisterEditEndpoints(api, registryService, cfg)
|
||||||
|
|
||||||
// Create request body
|
// Create request body
|
||||||
var requestBody []byte
|
var requestBody []byte
|
||||||
|
|
@ -338,7 +323,7 @@ func TestEditServerEndpoint(t *testing.T) {
|
||||||
assert.Contains(t, w.Body.String(), tc.expectedError)
|
assert.Contains(t, w.Body.String(), tc.expectedError)
|
||||||
}
|
}
|
||||||
|
|
||||||
mockRegistry.AssertExpectations(t)
|
// No mock assertions needed with real service
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ 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/config"
|
"github.com/modelcontextprotocol/registry/internal/config"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/database"
|
||||||
"github.com/modelcontextprotocol/registry/internal/service"
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
||||||
"github.com/modelcontextprotocol/registry/pkg/model"
|
"github.com/modelcontextprotocol/registry/pkg/model"
|
||||||
|
|
@ -36,7 +37,7 @@ func generateIntegrationTestJWTToken(cfg *config.Config, claims auth.JWTClaims)
|
||||||
|
|
||||||
func TestPublishIntegration(t *testing.T) {
|
func TestPublishIntegration(t *testing.T) {
|
||||||
// Setup fake service
|
// Setup fake service
|
||||||
registryService := service.NewFakeRegistryService()
|
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
|
||||||
|
|
||||||
// Create test config with a valid Ed25519 seed
|
// Create test config with a valid Ed25519 seed
|
||||||
testSeed := make([]byte, ed25519.SeedSize)
|
testSeed := make([]byte, ed25519.SeedSize)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -17,46 +16,14 @@ 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/config"
|
"github.com/modelcontextprotocol/registry/internal/config"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/database"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
||||||
"github.com/modelcontextprotocol/registry/pkg/model"
|
"github.com/modelcontextprotocol/registry/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockRegistryService is a mock implementation of the RegistryService interface
|
|
||||||
type MockRegistryService struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockRegistryService) List(cursor string, limit int) ([]apiv0.ServerJSON, string, error) {
|
|
||||||
args := m.Called(cursor, limit)
|
|
||||||
return args.Get(0).([]apiv0.ServerJSON), args.String(1), args.Error(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockRegistryService) GetByID(id string) (*apiv0.ServerJSON, error) {
|
|
||||||
args := m.Called(id)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(*apiv0.ServerJSON), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockRegistryService) Publish(request apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
|
|
||||||
args := m.Called(request)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(*apiv0.ServerJSON), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockRegistryService) EditServer(id string, request apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
|
|
||||||
args := m.Called(id, request)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(*apiv0.ServerJSON), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to generate a valid JWT token for testing
|
// Helper function to generate a valid JWT token for testing
|
||||||
func generateTestJWTToken(cfg *config.Config, claims auth.JWTClaims) (string, error) {
|
func generateTestJWTToken(cfg *config.Config, claims auth.JWTClaims) (string, error) {
|
||||||
|
|
@ -82,7 +49,7 @@ func TestPublishEndpoint(t *testing.T) {
|
||||||
requestBody interface{}
|
requestBody interface{}
|
||||||
tokenClaims *auth.JWTClaims
|
tokenClaims *auth.JWTClaims
|
||||||
authHeader string
|
authHeader string
|
||||||
setupMocks func(*MockRegistryService)
|
setupRegistryService func(service.RegistryService)
|
||||||
expectedStatus int
|
expectedStatus int
|
||||||
expectedError string
|
expectedError string
|
||||||
}{
|
}{
|
||||||
|
|
@ -107,8 +74,8 @@ func TestPublishEndpoint(t *testing.T) {
|
||||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
|
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
setupRegistryService: func(_ service.RegistryService) {
|
||||||
registry.On("Publish", mock.AnythingOfType("v0.ServerJSON")).Return(&apiv0.ServerJSON{}, nil)
|
// Empty registry - no setup needed
|
||||||
},
|
},
|
||||||
expectedStatus: http.StatusOK,
|
expectedStatus: http.StatusOK,
|
||||||
},
|
},
|
||||||
|
|
@ -132,8 +99,8 @@ func TestPublishEndpoint(t *testing.T) {
|
||||||
{Action: auth.PermissionActionPublish, ResourcePattern: "example/*"},
|
{Action: auth.PermissionActionPublish, ResourcePattern: "example/*"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
setupRegistryService: func(_ service.RegistryService) {
|
||||||
registry.On("Publish", mock.AnythingOfType("v0.ServerJSON")).Return(&apiv0.ServerJSON{}, nil)
|
// Empty registry - no setup needed
|
||||||
},
|
},
|
||||||
expectedStatus: http.StatusOK,
|
expectedStatus: http.StatusOK,
|
||||||
},
|
},
|
||||||
|
|
@ -141,7 +108,9 @@ func TestPublishEndpoint(t *testing.T) {
|
||||||
name: "missing authorization header",
|
name: "missing authorization header",
|
||||||
requestBody: apiv0.ServerJSON{},
|
requestBody: apiv0.ServerJSON{},
|
||||||
authHeader: "", // Empty auth header
|
authHeader: "", // Empty auth header
|
||||||
setupMocks: func(_ *MockRegistryService) {},
|
setupRegistryService: func(_ service.RegistryService) {
|
||||||
|
// Empty registry - no setup needed
|
||||||
|
},
|
||||||
expectedStatus: http.StatusUnprocessableEntity,
|
expectedStatus: http.StatusUnprocessableEntity,
|
||||||
expectedError: "required header parameter is missing",
|
expectedError: "required header parameter is missing",
|
||||||
},
|
},
|
||||||
|
|
@ -153,7 +122,9 @@ func TestPublishEndpoint(t *testing.T) {
|
||||||
VersionDetail: model.VersionDetail{Version: "1.0.0"},
|
VersionDetail: model.VersionDetail{Version: "1.0.0"},
|
||||||
},
|
},
|
||||||
authHeader: "InvalidFormat",
|
authHeader: "InvalidFormat",
|
||||||
setupMocks: func(_ *MockRegistryService) {},
|
setupRegistryService: func(_ service.RegistryService) {
|
||||||
|
// Empty registry - no setup needed
|
||||||
|
},
|
||||||
expectedStatus: http.StatusUnauthorized,
|
expectedStatus: http.StatusUnauthorized,
|
||||||
expectedError: "Invalid Authorization header format",
|
expectedError: "Invalid Authorization header format",
|
||||||
},
|
},
|
||||||
|
|
@ -167,7 +138,9 @@ func TestPublishEndpoint(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authHeader: "Bearer invalidToken",
|
authHeader: "Bearer invalidToken",
|
||||||
setupMocks: func(_ *MockRegistryService) {},
|
setupRegistryService: func(_ service.RegistryService) {
|
||||||
|
// Empty registry - no setup needed
|
||||||
|
},
|
||||||
expectedStatus: http.StatusUnauthorized,
|
expectedStatus: http.StatusUnauthorized,
|
||||||
expectedError: "Invalid or expired Registry JWT token",
|
expectedError: "Invalid or expired Registry JWT token",
|
||||||
},
|
},
|
||||||
|
|
@ -191,7 +164,9 @@ func TestPublishEndpoint(t *testing.T) {
|
||||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
|
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setupMocks: func(_ *MockRegistryService) {},
|
setupRegistryService: func(_ service.RegistryService) {
|
||||||
|
// Empty registry - no setup needed
|
||||||
|
},
|
||||||
expectedStatus: http.StatusForbidden,
|
expectedStatus: http.StatusForbidden,
|
||||||
expectedError: "You do not have permission to publish this server",
|
expectedError: "You do not have permission to publish this server",
|
||||||
},
|
},
|
||||||
|
|
@ -215,28 +190,41 @@ func TestPublishEndpoint(t *testing.T) {
|
||||||
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
|
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
setupRegistryService: func(registry service.RegistryService) {
|
||||||
registry.On("Publish", mock.AnythingOfType("v0.ServerJSON")).Return(nil, errors.New("cannot publish duplicate version"))
|
// Pre-publish the same server to cause duplicate version error
|
||||||
|
existingServer := apiv0.ServerJSON{
|
||||||
|
Name: "example/test-server",
|
||||||
|
Description: "Existing test server",
|
||||||
|
VersionDetail: model.VersionDetail{
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Repository: model.Repository{
|
||||||
|
URL: "https://github.com/example/test-server-existing",
|
||||||
|
Source: "github",
|
||||||
|
ID: "example/test-server-existing",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, _ = registry.Publish(existingServer)
|
||||||
},
|
},
|
||||||
expectedStatus: http.StatusBadRequest,
|
expectedStatus: http.StatusBadRequest,
|
||||||
expectedError: "Failed to publish server",
|
expectedError: "invalid version: cannot publish duplicate version",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
// Create mocks
|
// Create registry service
|
||||||
mockRegistry := new(MockRegistryService)
|
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
|
||||||
|
|
||||||
// Setup mocks
|
// Setup registry service
|
||||||
tc.setupMocks(mockRegistry)
|
tc.setupRegistryService(registryService)
|
||||||
|
|
||||||
// Create a new ServeMux and Huma API
|
// Create a new ServeMux and Huma API
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||||
|
|
||||||
// Register the endpoint with test config
|
// Register the endpoint with test config
|
||||||
v0.RegisterPublishEndpoint(api, mockRegistry, testConfig)
|
v0.RegisterPublishEndpoint(api, registryService, testConfig)
|
||||||
|
|
||||||
// Prepare request body
|
// Prepare request body
|
||||||
var requestBody []byte
|
var requestBody []byte
|
||||||
|
|
@ -272,8 +260,7 @@ func TestPublishEndpoint(t *testing.T) {
|
||||||
assert.Contains(t, rr.Body.String(), tc.expectedError)
|
assert.Contains(t, rr.Body.String(), tc.expectedError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify mock expectations
|
// No mock expectations to verify
|
||||||
mockRegistry.AssertExpectations(t)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package v0_test
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -12,67 +11,29 @@ import (
|
||||||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/database"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
||||||
"github.com/modelcontextprotocol/registry/pkg/model"
|
"github.com/modelcontextprotocol/registry/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServersListEndpoint(t *testing.T) {
|
func TestServersListEndpoint(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
queryParams string
|
queryParams string
|
||||||
setupMocks func(*MockRegistryService)
|
setupRegistryService func(service.RegistryService)
|
||||||
expectedStatus int
|
expectedStatus int
|
||||||
expectedServers []apiv0.ServerJSON
|
expectedServers []apiv0.ServerJSON
|
||||||
expectedMeta *v0.Metadata
|
expectedMeta *v0.Metadata
|
||||||
expectedError string
|
expectedError string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "successful list with default parameters",
|
name: "successful list with default parameters",
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
setupRegistryService: func(registry service.RegistryService) {
|
||||||
servers := []apiv0.ServerJSON{
|
// Publish test servers
|
||||||
{
|
server1 := apiv0.ServerJSON{
|
||||||
Name: "test-server-1",
|
Name: "com.example/test-server-1",
|
||||||
Description: "First test server",
|
|
||||||
Repository: model.Repository{
|
|
||||||
URL: "https://github.com/example/test-server-1",
|
|
||||||
Source: "github",
|
|
||||||
ID: "example/test-server-1",
|
|
||||||
},
|
|
||||||
VersionDetail: model.VersionDetail{
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Meta: &apiv0.ServerMeta{
|
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
|
||||||
ID: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "test-server-2",
|
|
||||||
Description: "Second test server",
|
|
||||||
Repository: model.Repository{
|
|
||||||
URL: "https://github.com/example/test-server-2",
|
|
||||||
Source: "github",
|
|
||||||
ID: "example/test-server-2",
|
|
||||||
},
|
|
||||||
VersionDetail: model.VersionDetail{
|
|
||||||
Version: "2.0.0",
|
|
||||||
},
|
|
||||||
Meta: &apiv0.ServerMeta{
|
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
|
||||||
ID: "550e8400-e29b-41d4-a716-446655440002",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
registry.Mock.On("List", "", 30).Return(servers, "", nil)
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedServers: []apiv0.ServerJSON{
|
|
||||||
{
|
|
||||||
Name: "test-server-1",
|
|
||||||
Description: "First test server",
|
Description: "First test server",
|
||||||
Repository: model.Repository{
|
Repository: model.Repository{
|
||||||
URL: "https://github.com/example/test-server-1",
|
URL: "https://github.com/example/test-server-1",
|
||||||
|
|
@ -82,14 +43,9 @@ func TestServersListEndpoint(t *testing.T) {
|
||||||
VersionDetail: model.VersionDetail{
|
VersionDetail: model.VersionDetail{
|
||||||
Version: "1.0.0",
|
Version: "1.0.0",
|
||||||
},
|
},
|
||||||
Meta: &apiv0.ServerMeta{
|
}
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
server2 := apiv0.ServerJSON{
|
||||||
ID: "550e8400-e29b-41d4-a716-446655440001",
|
Name: "com.example/test-server-2",
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "test-server-2",
|
|
||||||
Description: "Second test server",
|
Description: "Second test server",
|
||||||
Repository: model.Repository{
|
Repository: model.Repository{
|
||||||
URL: "https://github.com/example/test-server-2",
|
URL: "https://github.com/example/test-server-2",
|
||||||
|
|
@ -99,44 +55,19 @@ func TestServersListEndpoint(t *testing.T) {
|
||||||
VersionDetail: model.VersionDetail{
|
VersionDetail: model.VersionDetail{
|
||||||
Version: "2.0.0",
|
Version: "2.0.0",
|
||||||
},
|
},
|
||||||
Meta: &apiv0.ServerMeta{
|
}
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
_, _ = registry.Publish(server1)
|
||||||
ID: "550e8400-e29b-41d4-a716-446655440002",
|
_, _ = registry.Publish(server2)
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedServers: nil, // Will be verified differently since IDs are dynamic
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "successful list with cursor and limit",
|
name: "successful list with cursor and limit",
|
||||||
queryParams: "?cursor=550e8400-e29b-41d4-a716-446655440000&limit=10",
|
queryParams: "?limit=10",
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
setupRegistryService: func(registry service.RegistryService) {
|
||||||
servers := []apiv0.ServerJSON{
|
server := apiv0.ServerJSON{
|
||||||
{
|
Name: "com.example/test-server-3",
|
||||||
Name: "test-server-3",
|
|
||||||
Description: "Third test server",
|
|
||||||
Repository: model.Repository{
|
|
||||||
URL: "https://github.com/example/test-server-3",
|
|
||||||
Source: "github",
|
|
||||||
ID: "example/test-server-3",
|
|
||||||
},
|
|
||||||
VersionDetail: model.VersionDetail{
|
|
||||||
Version: "1.5.0",
|
|
||||||
},
|
|
||||||
Meta: &apiv0.ServerMeta{
|
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
|
||||||
ID: "550e8400-e29b-41d4-a716-446655440003",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
nextCursor := uuid.New().String()
|
|
||||||
registry.Mock.On("List", mock.AnythingOfType("string"), 10).Return(servers, nextCursor, nil)
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
|
||||||
expectedServers: []apiv0.ServerJSON{
|
|
||||||
{
|
|
||||||
Name: "test-server-3",
|
|
||||||
Description: "Third test server",
|
Description: "Third test server",
|
||||||
Repository: model.Repository{
|
Repository: model.Repository{
|
||||||
URL: "https://github.com/example/test-server-3",
|
URL: "https://github.com/example/test-server-3",
|
||||||
|
|
@ -146,75 +77,69 @@ func TestServersListEndpoint(t *testing.T) {
|
||||||
VersionDetail: model.VersionDetail{
|
VersionDetail: model.VersionDetail{
|
||||||
Version: "1.5.0",
|
Version: "1.5.0",
|
||||||
},
|
},
|
||||||
Meta: &apiv0.ServerMeta{
|
}
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
_, _ = registry.Publish(server)
|
||||||
ID: "550e8400-e29b-41d4-a716-446655440003",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
expectedMeta: &v0.Metadata{
|
expectedStatus: http.StatusOK,
|
||||||
NextCursor: "", // This will be dynamically set in the test
|
expectedServers: nil, // Will be verified differently since IDs are dynamic
|
||||||
Count: 1,
|
},
|
||||||
|
{
|
||||||
|
name: "successful list with limit capping at 100",
|
||||||
|
queryParams: "?limit=150",
|
||||||
|
setupRegistryService: func(_ service.RegistryService) {},
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity, // Huma rejects values > maximum
|
||||||
|
expectedError: "validation failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid cursor parameter",
|
||||||
|
queryParams: "?cursor=invalid-uuid",
|
||||||
|
setupRegistryService: func(_ service.RegistryService) {},
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
|
||||||
|
expectedError: "validation failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid limit parameter - non-numeric",
|
||||||
|
queryParams: "?limit=abc",
|
||||||
|
setupRegistryService: func(_ service.RegistryService) {},
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
|
||||||
|
expectedError: "validation failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid limit parameter - zero",
|
||||||
|
queryParams: "?limit=0",
|
||||||
|
setupRegistryService: func(_ service.RegistryService) {},
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
|
||||||
|
expectedError: "validation failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid limit parameter - negative",
|
||||||
|
queryParams: "?limit=-5",
|
||||||
|
setupRegistryService: func(_ service.RegistryService) {},
|
||||||
|
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
|
||||||
|
expectedError: "validation failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty registry returns success",
|
||||||
|
setupRegistryService: func(_ service.RegistryService) {
|
||||||
|
// Test empty registry - empty setup
|
||||||
},
|
},
|
||||||
},
|
expectedStatus: http.StatusOK,
|
||||||
{
|
expectedServers: []apiv0.ServerJSON{},
|
||||||
name: "successful list with limit capping at 100",
|
|
||||||
queryParams: "?limit=150",
|
|
||||||
setupMocks: func(_ *MockRegistryService) {},
|
|
||||||
expectedStatus: http.StatusUnprocessableEntity, // Huma rejects values > maximum
|
|
||||||
expectedError: "validation failed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid cursor parameter",
|
|
||||||
queryParams: "?cursor=invalid-uuid",
|
|
||||||
setupMocks: func(_ *MockRegistryService) {},
|
|
||||||
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
|
|
||||||
expectedError: "validation failed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid limit parameter - non-numeric",
|
|
||||||
queryParams: "?limit=abc",
|
|
||||||
setupMocks: func(_ *MockRegistryService) {},
|
|
||||||
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
|
|
||||||
expectedError: "validation failed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid limit parameter - zero",
|
|
||||||
queryParams: "?limit=0",
|
|
||||||
setupMocks: func(_ *MockRegistryService) {},
|
|
||||||
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
|
|
||||||
expectedError: "validation failed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid limit parameter - negative",
|
|
||||||
queryParams: "?limit=-5",
|
|
||||||
setupMocks: func(_ *MockRegistryService) {},
|
|
||||||
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
|
|
||||||
expectedError: "validation failed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "registry service error",
|
|
||||||
setupMocks: func(registry *MockRegistryService) {
|
|
||||||
registry.Mock.On("List", "", 30).Return([]apiv0.ServerJSON{}, "", errors.New("database connection error"))
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusInternalServerError,
|
|
||||||
expectedError: "Failed to get registry list",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
// Create mock registry service
|
// Create mock registry service
|
||||||
mockRegistry := new(MockRegistryService)
|
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
|
||||||
tc.setupMocks(mockRegistry)
|
tc.setupRegistryService(registryService)
|
||||||
|
|
||||||
// Create a new test API
|
// Create a new test API
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||||
|
|
||||||
// Register the servers endpoints
|
// Register the servers endpoints
|
||||||
v0.RegisterServersEndpoints(api, mockRegistry)
|
v0.RegisterServersEndpoints(api, registryService)
|
||||||
|
|
||||||
// Create request
|
// Create request
|
||||||
url := "/v0/servers" + tc.queryParams
|
url := "/v0/servers" + tc.queryParams
|
||||||
|
|
@ -242,13 +167,26 @@ func TestServersListEndpoint(t *testing.T) {
|
||||||
// Check the response data
|
// Check the response data
|
||||||
if tc.expectedServers != nil {
|
if tc.expectedServers != nil {
|
||||||
assert.Equal(t, tc.expectedServers, resp.Servers)
|
assert.Equal(t, tc.expectedServers, resp.Servers)
|
||||||
|
} else {
|
||||||
|
// For tests with dynamic data, check structure and count
|
||||||
|
assert.NotEmpty(t, resp.Servers, "Expected at least one server")
|
||||||
|
for _, server := range resp.Servers {
|
||||||
|
assert.NotEmpty(t, server.Name)
|
||||||
|
assert.NotEmpty(t, server.Description)
|
||||||
|
assert.NotNil(t, server.Meta)
|
||||||
|
assert.NotNil(t, server.Meta.IOModelContextProtocolRegistry)
|
||||||
|
assert.NotEmpty(t, server.Meta.IOModelContextProtocolRegistry.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check metadata if expected
|
// Check metadata if expected
|
||||||
if tc.expectedMeta != nil {
|
if tc.expectedMeta != nil {
|
||||||
assert.Equal(t, tc.expectedMeta.Count, resp.Metadata.Count)
|
assert.NotNil(t, resp.Metadata, "Expected metadata to be present")
|
||||||
if tc.expectedMeta.NextCursor != "" {
|
if resp.Metadata != nil {
|
||||||
assert.NotEmpty(t, resp.Metadata.NextCursor)
|
assert.Equal(t, tc.expectedMeta.Count, resp.Metadata.Count)
|
||||||
|
if tc.expectedMeta.NextCursor != "" {
|
||||||
|
assert.NotEmpty(t, resp.Metadata.NextCursor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if tc.expectedError != "" {
|
} else if tc.expectedError != "" {
|
||||||
|
|
@ -257,84 +195,58 @@ func TestServersListEndpoint(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify mock expectations
|
// Verify mock expectations
|
||||||
mockRegistry.AssertExpectations(t)
|
// No expectations to verify with real service
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServersDetailEndpoint(t *testing.T) {
|
func TestServersDetailEndpoint(t *testing.T) {
|
||||||
|
// Create mock registry service
|
||||||
|
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
|
||||||
|
|
||||||
|
testServer, err := registryService.Publish(apiv0.ServerJSON{
|
||||||
|
Name: "com.example/test-server",
|
||||||
|
Description: "A test server",
|
||||||
|
VersionDetail: model.VersionDetail{
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
serverID string
|
serverID string
|
||||||
setupMocks func(*MockRegistryService, string)
|
|
||||||
expectedStatus int
|
expectedStatus int
|
||||||
expectedServer *apiv0.ServerJSON
|
expectedServer *apiv0.ServerJSON
|
||||||
expectedError string
|
expectedError string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "successful get server detail",
|
name: "successful get server detail",
|
||||||
serverID: uuid.New().String(),
|
serverID: testServer.Meta.IOModelContextProtocolRegistry.ID,
|
||||||
setupMocks: func(registry *MockRegistryService, serverID string) {
|
|
||||||
serverDetail := &apiv0.ServerJSON{
|
|
||||||
Name: "test-server-detail",
|
|
||||||
Description: "Test server detail",
|
|
||||||
Repository: model.Repository{
|
|
||||||
URL: "https://github.com/example/test-server-detail",
|
|
||||||
Source: "github",
|
|
||||||
ID: "example/test-server-detail",
|
|
||||||
},
|
|
||||||
VersionDetail: model.VersionDetail{
|
|
||||||
Version: "2.0.0",
|
|
||||||
},
|
|
||||||
Meta: &apiv0.ServerMeta{
|
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
|
||||||
ID: serverID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
registry.Mock.On("GetByID", serverID).Return(serverDetail, nil)
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusOK,
|
expectedStatus: http.StatusOK,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid server ID format",
|
name: "invalid server ID format",
|
||||||
serverID: "invalid-uuid",
|
serverID: "invalid-uuid",
|
||||||
setupMocks: func(_ *MockRegistryService, _ string) {},
|
|
||||||
expectedStatus: http.StatusUnprocessableEntity,
|
expectedStatus: http.StatusUnprocessableEntity,
|
||||||
expectedError: "validation failed",
|
expectedError: "validation failed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "server not found",
|
name: "server not found",
|
||||||
serverID: uuid.New().String(),
|
serverID: uuid.New().String(),
|
||||||
setupMocks: func(registry *MockRegistryService, serverID string) {
|
|
||||||
registry.Mock.On("GetByID", serverID).Return(nil, errors.New("record not found"))
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusNotFound,
|
expectedStatus: http.StatusNotFound,
|
||||||
expectedError: "Server not found",
|
expectedError: "Server not found",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "registry service error",
|
|
||||||
serverID: uuid.New().String(),
|
|
||||||
setupMocks: func(registry *MockRegistryService, serverID string) {
|
|
||||||
registry.Mock.On("GetByID", serverID).Return(nil, errors.New("database connection error"))
|
|
||||||
},
|
|
||||||
expectedStatus: http.StatusInternalServerError,
|
|
||||||
expectedError: "Failed to get server details",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
// Create mock registry service
|
|
||||||
mockRegistry := new(MockRegistryService)
|
|
||||||
tc.setupMocks(mockRegistry, tc.serverID)
|
|
||||||
|
|
||||||
// Create a new test API
|
// Create a new test API
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||||
|
|
||||||
// Register the servers endpoints
|
// Register the servers endpoints
|
||||||
v0.RegisterServersEndpoints(api, mockRegistry)
|
v0.RegisterServersEndpoints(api, registryService)
|
||||||
|
|
||||||
// Create request
|
// Create request
|
||||||
url := "/v0/servers/" + tc.serverID
|
url := "/v0/servers/" + tc.serverID
|
||||||
|
|
@ -364,7 +276,7 @@ func TestServersDetailEndpoint(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify mock expectations
|
// Verify mock expectations
|
||||||
mockRegistry.AssertExpectations(t)
|
// No expectations to verify with real service
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -372,42 +284,36 @@ func TestServersDetailEndpoint(t *testing.T) {
|
||||||
// TestServersEndpointsIntegration tests the servers endpoints with actual HTTP requests
|
// TestServersEndpointsIntegration tests the servers endpoints with actual HTTP requests
|
||||||
func TestServersEndpointsIntegration(t *testing.T) {
|
func TestServersEndpointsIntegration(t *testing.T) {
|
||||||
// Create mock registry service
|
// Create mock registry service
|
||||||
mockRegistry := new(MockRegistryService)
|
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
|
||||||
|
|
||||||
// Test data
|
// Test data - publish a server and get its actual ID
|
||||||
serverID := uuid.New().String()
|
testServer := apiv0.ServerJSON{
|
||||||
servers := []apiv0.ServerJSON{
|
Name: "com.example/integration-test-server",
|
||||||
{
|
Description: "Integration test server",
|
||||||
Name: "integration-test-server",
|
Repository: model.Repository{
|
||||||
Description: "Integration test server",
|
URL: "https://github.com/example/integration-test",
|
||||||
Repository: model.Repository{
|
Source: "github",
|
||||||
URL: "https://github.com/example/integration-test",
|
ID: "example/integration-test",
|
||||||
Source: "github",
|
},
|
||||||
ID: "example/integration-test",
|
VersionDetail: model.VersionDetail{
|
||||||
},
|
Version: "1.0.0",
|
||||||
VersionDetail: model.VersionDetail{
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Meta: &apiv0.ServerMeta{
|
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
|
||||||
ID: serverID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
serverDetail := &servers[0]
|
published, err := registryService.Publish(testServer)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, published)
|
||||||
|
|
||||||
// Setup mocks
|
serverID := published.Meta.IOModelContextProtocolRegistry.ID
|
||||||
mockRegistry.Mock.On("List", "", 30).Return(servers, "", nil)
|
servers := []apiv0.ServerJSON{*published}
|
||||||
mockRegistry.Mock.On("GetByID", serverID).Return(serverDetail, nil)
|
serverDetail := published
|
||||||
|
|
||||||
// Create a new test API
|
// Create a new test API
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||||
|
|
||||||
// Register the servers endpoints
|
// Register the servers endpoints
|
||||||
v0.RegisterServersEndpoints(api, mockRegistry)
|
v0.RegisterServersEndpoints(api, registryService)
|
||||||
|
|
||||||
// Create test server
|
// Create test server
|
||||||
server := httptest.NewServer(mux)
|
server := httptest.NewServer(mux)
|
||||||
|
|
@ -441,8 +347,14 @@ func TestServersEndpointsIntegration(t *testing.T) {
|
||||||
err = json.NewDecoder(resp.Body).Decode(&listResp)
|
err = json.NewDecoder(resp.Body).Decode(&listResp)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Check the response data
|
// Check the response data (excluding timestamps which will be different)
|
||||||
assert.Equal(t, servers, listResp.Servers)
|
assert.Len(t, listResp.Servers, len(servers))
|
||||||
|
if len(listResp.Servers) > 0 {
|
||||||
|
assert.Equal(t, servers[0].Name, listResp.Servers[0].Name)
|
||||||
|
assert.Equal(t, servers[0].Description, listResp.Servers[0].Description)
|
||||||
|
assert.Equal(t, servers[0].Repository, listResp.Servers[0].Repository)
|
||||||
|
assert.Equal(t, servers[0].VersionDetail, listResp.Servers[0].VersionDetail)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test get server detail endpoint
|
// Test get server detail endpoint
|
||||||
|
|
@ -471,10 +383,13 @@ func TestServersEndpointsIntegration(t *testing.T) {
|
||||||
err = json.NewDecoder(resp.Body).Decode(&serverDetailResp)
|
err = json.NewDecoder(resp.Body).Decode(&serverDetailResp)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Check the response data
|
// Check the response data (excluding timestamps which will be different)
|
||||||
assert.Equal(t, *serverDetail, serverDetailResp)
|
assert.Equal(t, serverDetail.Name, serverDetailResp.Name)
|
||||||
|
assert.Equal(t, serverDetail.Description, serverDetailResp.Description)
|
||||||
|
assert.Equal(t, serverDetail.Repository, serverDetailResp.Repository)
|
||||||
|
assert.Equal(t, serverDetail.VersionDetail, serverDetailResp.VersionDetail)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Verify mock expectations
|
// Verify mock expectations
|
||||||
mockRegistry.AssertExpectations(t)
|
// No expectations to verify with real service
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,43 +8,33 @@ import (
|
||||||
|
|
||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
||||||
"github.com/modelcontextprotocol/registry/internal/api/router"
|
"github.com/modelcontextprotocol/registry/internal/api/router"
|
||||||
"github.com/modelcontextprotocol/registry/internal/config"
|
"github.com/modelcontextprotocol/registry/internal/config"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/database"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
"github.com/modelcontextprotocol/registry/internal/telemetry"
|
"github.com/modelcontextprotocol/registry/internal/telemetry"
|
||||||
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
||||||
"github.com/modelcontextprotocol/registry/pkg/model"
|
"github.com/modelcontextprotocol/registry/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func mockServerEndpoint(registry *MockRegistryService, serverID string) {
|
func TestPrometheusHandler(t *testing.T) {
|
||||||
serverDetail := &apiv0.ServerJSON{
|
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
|
||||||
Name: "test-server-detail",
|
server, err := registryService.Publish(apiv0.ServerJSON{
|
||||||
|
Name: "io.github.example/test-server",
|
||||||
Description: "Test server detail",
|
Description: "Test server detail",
|
||||||
Repository: model.Repository{
|
Repository: model.Repository{
|
||||||
URL: "https://github.com/example/test-server-detail",
|
URL: "https://github.com/example/test-server",
|
||||||
Source: "github",
|
Source: "github",
|
||||||
ID: "example/test-server-detail",
|
ID: "example/test-server",
|
||||||
},
|
},
|
||||||
VersionDetail: model.VersionDetail{
|
VersionDetail: model.VersionDetail{
|
||||||
Version: "2.0.0",
|
Version: "2.0.0",
|
||||||
},
|
},
|
||||||
Meta: &apiv0.ServerMeta{
|
})
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
assert.NoError(t, err)
|
||||||
ID: serverID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
registry.Mock.On("GetByID", serverID).Return(serverDetail, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrometheusHandler(t *testing.T) {
|
|
||||||
mockRegistry := new(MockRegistryService)
|
|
||||||
|
|
||||||
serverID := uuid.New().String()
|
|
||||||
mockServerEndpoint(mockRegistry, serverID)
|
|
||||||
|
|
||||||
cfg := config.NewConfig()
|
cfg := config.NewConfig()
|
||||||
shutdownTelemetry, metrics, _ := telemetry.InitMetrics("dev")
|
shutdownTelemetry, metrics, _ := telemetry.InitMetrics("dev")
|
||||||
|
|
@ -57,13 +47,13 @@ func TestPrometheusHandler(t *testing.T) {
|
||||||
router.WithSkipPaths("/health", "/metrics", "/ping", "/docs"),
|
router.WithSkipPaths("/health", "/metrics", "/ping", "/docs"),
|
||||||
))
|
))
|
||||||
v0.RegisterHealthEndpoint(api, cfg, metrics)
|
v0.RegisterHealthEndpoint(api, cfg, metrics)
|
||||||
v0.RegisterServersEndpoints(api, mockRegistry)
|
v0.RegisterServersEndpoints(api, registryService)
|
||||||
|
|
||||||
// Add /metrics for Prometheus metrics using promhttp
|
// Add /metrics for Prometheus metrics using promhttp
|
||||||
mux.Handle("/metrics", metrics.PrometheusHandler())
|
mux.Handle("/metrics", metrics.PrometheusHandler())
|
||||||
|
|
||||||
// Create request
|
// Create request
|
||||||
url := "/v0/servers/" + serverID
|
url := "/v0/servers/" + server.Meta.IOModelContextProtocolRegistry.ID
|
||||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,21 +17,22 @@ var (
|
||||||
ErrMaxServersReached = errors.New("maximum number of versions for this server reached (10000): please reach out at https://github.com/modelcontextprotocol/registry to explain your use case")
|
ErrMaxServersReached = errors.New("maximum number of versions for this server reached (10000): please reach out at https://github.com/modelcontextprotocol/registry to explain your use case")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Database defines the interface for database operations with extension wrapper architecture
|
// ServerFilter defines filtering options for server queries
|
||||||
|
type ServerFilter struct {
|
||||||
|
Name *string // for finding versions of same server
|
||||||
|
RemoteURL *string // for duplicate URL detection
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database defines the interface for database operations
|
||||||
type Database interface {
|
type Database interface {
|
||||||
// List retrieves all ServerRecord entries with optional filtering
|
// Retrieve server entries with optional filtering
|
||||||
List(ctx context.Context, filter map[string]any, cursor string, limit int) ([]*apiv0.ServerJSON, string, error)
|
List(ctx context.Context, filter *ServerFilter, cursor string, limit int) ([]*apiv0.ServerJSON, string, error)
|
||||||
// GetByID retrieves a single ServerRecord by its ID
|
// Retrieve a single server by its ID
|
||||||
GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error)
|
GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error)
|
||||||
// Publish adds a new server to the database with separated server.json and extensions
|
// CreateServer adds a new server to the database
|
||||||
// The registryMetadata contains metadata determined by the service layer (e.g., is_latest, timestamps)
|
CreateServer(ctx context.Context, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error)
|
||||||
Publish(ctx context.Context, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv0.RegistryExtensions) (*apiv0.ServerJSON, error)
|
// UpdateServer updates an existing server record
|
||||||
// UpdateLatestFlag updates the is_latest flag for a specific server record
|
UpdateServer(ctx context.Context, id string, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error)
|
||||||
UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error
|
|
||||||
// UpdateServer updates an existing server record with new server details
|
|
||||||
UpdateServer(ctx context.Context, id string, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}) (*apiv0.ServerJSON, error)
|
|
||||||
// ImportSeed imports initial data from a seed file
|
|
||||||
ImportSeed(ctx context.Context, seedFilePath string) error
|
|
||||||
// Close closes the database connection
|
// Close closes the database connection
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
@ -45,15 +46,3 @@ const (
|
||||||
// ConnectionTypePostgreSQL represents a PostgreSQL database connection
|
// ConnectionTypePostgreSQL represents a PostgreSQL database connection
|
||||||
ConnectionTypePostgreSQL ConnectionType = "postgresql"
|
ConnectionTypePostgreSQL ConnectionType = "postgresql"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConnectionInfo provides information about the database connection
|
|
||||||
type ConnectionInfo struct {
|
|
||||||
// Type indicates the type of database connection
|
|
||||||
Type ConnectionType
|
|
||||||
// IsConnected indicates whether the database is currently connected
|
|
||||||
IsConnected bool
|
|
||||||
// Raw provides access to the underlying connection object, which will vary by implementation
|
|
||||||
// For PostgreSQL, this will be *pgx.Conn
|
|
||||||
// For MemoryDB, this will be map[string]*model.MCPRegistry
|
|
||||||
Raw any
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
package database_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/registry/internal/database"
|
|
||||||
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
|
||||||
"github.com/modelcontextprotocol/registry/pkg/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestReadSeedFile_LocalFile(t *testing.T) {
|
|
||||||
// Create a temporary seed file in extension wrapper format
|
|
||||||
tempFile := "/tmp/test_seed.json"
|
|
||||||
seedData := []apiv0.ServerJSON{
|
|
||||||
{
|
|
||||||
Name: "com.example/test-server-1",
|
|
||||||
Description: "Test server 1",
|
|
||||||
Repository: model.Repository{
|
|
||||||
URL: "https://github.com/test/repo1",
|
|
||||||
Source: "github",
|
|
||||||
ID: "123",
|
|
||||||
},
|
|
||||||
VersionDetail: model.VersionDetail{
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Remotes: []model.Remote{
|
|
||||||
{
|
|
||||||
URL: "https://example.com/remote",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Meta: &apiv0.ServerMeta{
|
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
|
||||||
ID: "test-id-1",
|
|
||||||
PublishedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
||||||
IsLatest: true,
|
|
||||||
ReleaseDate: "2023-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write seed data to temp file
|
|
||||||
data, err := json.Marshal(seedData)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
err = func() error {
|
|
||||||
f, err := os.Create(tempFile)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
_, err = f.Write(data)
|
|
||||||
return err
|
|
||||||
}()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
defer os.Remove(tempFile)
|
|
||||||
|
|
||||||
// Test reading the file
|
|
||||||
result, err := database.ReadSeedFile(context.Background(), tempFile)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Len(t, result, 1)
|
|
||||||
assert.Equal(t, "com.example/test-server-1", result[0].Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadSeedFile_DirectHTTPURL(t *testing.T) {
|
|
||||||
// Create a test HTTP server that serves seed JSON directly in extension wrapper format
|
|
||||||
seedData := []apiv0.ServerJSON{
|
|
||||||
{
|
|
||||||
Name: "com.example/test-server-1",
|
|
||||||
Description: "Test server 1",
|
|
||||||
Remotes: []model.Remote{
|
|
||||||
{
|
|
||||||
URL: "https://example.com/remote",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Meta: &apiv0.ServerMeta{
|
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
|
||||||
ID: "test-id-1",
|
|
||||||
PublishedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
||||||
IsLatest: true,
|
|
||||||
ReleaseDate: "2023-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if err := json.NewEncoder(w).Encode(seedData); err != nil {
|
|
||||||
http.Error(w, "Failed to encode JSON", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Test reading from HTTP URL ending in .json
|
|
||||||
result, err := database.ReadSeedFile(context.Background(), server.URL+"/seed.json")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Len(t, result, 1)
|
|
||||||
assert.Equal(t, "com.example/test-server-1", result[0].Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadSeedFile_RegistryURL(t *testing.T) {
|
|
||||||
// Create mock registry responses
|
|
||||||
server1 := apiv0.ServerJSON{
|
|
||||||
Name: "Test Server 1",
|
|
||||||
Description: "First test server",
|
|
||||||
Packages: []model.Package{
|
|
||||||
{
|
|
||||||
RegistryType: "npm",
|
|
||||||
RegistryBaseURL: "https://registry.npmjs.org",
|
|
||||||
Identifier: "test-package-1",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Meta: &apiv0.ServerMeta{
|
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
|
||||||
ID: "server-1",
|
|
||||||
PublishedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
||||||
IsLatest: true,
|
|
||||||
ReleaseDate: "2023-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
server2 := apiv0.ServerJSON{
|
|
||||||
Name: "Test Server 2",
|
|
||||||
Description: "Second test server",
|
|
||||||
Packages: []model.Package{
|
|
||||||
{
|
|
||||||
RegistryType: "npm",
|
|
||||||
RegistryBaseURL: "https://registry.npmjs.org",
|
|
||||||
Identifier: "test-package-2",
|
|
||||||
Version: "2.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Meta: &apiv0.ServerMeta{
|
|
||||||
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
|
||||||
ID: "server-2",
|
|
||||||
PublishedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
||||||
IsLatest: true,
|
|
||||||
ReleaseDate: "2023-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
serverDetail1 := apiv0.ServerJSON{
|
|
||||||
Name: "Test Server 1",
|
|
||||||
Description: "First test server",
|
|
||||||
Packages: []model.Package{
|
|
||||||
{
|
|
||||||
RegistryType: "npm",
|
|
||||||
RegistryBaseURL: "https://registry.npmjs.org",
|
|
||||||
Identifier: "test-package-1",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
serverDetail2 := apiv0.ServerJSON{
|
|
||||||
Name: "Test Server 2",
|
|
||||||
Description: "Second test server",
|
|
||||||
Packages: []model.Package{
|
|
||||||
{
|
|
||||||
RegistryType: "npm",
|
|
||||||
RegistryBaseURL: "https://registry.npmjs.org",
|
|
||||||
Identifier: "test-package-2",
|
|
||||||
Version: "2.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a test HTTP server that simulates the registry API
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
// Handle /v0/servers endpoint (paginated)
|
|
||||||
mux.HandleFunc("/v0/servers", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cursor := r.URL.Query().Get("cursor")
|
|
||||||
|
|
||||||
type Metadata struct {
|
|
||||||
NextCursor string `json:"next_cursor,omitempty"`
|
|
||||||
Count int `json:"count,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaginatedResponse struct {
|
|
||||||
Servers []apiv0.ServerJSON `json:"servers"`
|
|
||||||
Metadata *Metadata `json:"metadata,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var response PaginatedResponse
|
|
||||||
switch cursor {
|
|
||||||
case "":
|
|
||||||
// First page
|
|
||||||
response = PaginatedResponse{
|
|
||||||
Servers: []apiv0.ServerJSON{server1},
|
|
||||||
Metadata: &Metadata{
|
|
||||||
NextCursor: "next-cursor-1",
|
|
||||||
Count: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
case "next-cursor-1":
|
|
||||||
// Second page
|
|
||||||
response = PaginatedResponse{
|
|
||||||
Servers: []apiv0.ServerJSON{server2},
|
|
||||||
Metadata: &Metadata{
|
|
||||||
Count: 1,
|
|
||||||
// No NextCursor means end of pagination
|
|
||||||
},
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// No more pages
|
|
||||||
response = PaginatedResponse{
|
|
||||||
Servers: []apiv0.ServerJSON{},
|
|
||||||
Metadata: &Metadata{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
||||||
http.Error(w, "Failed to encode JSON", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle individual server detail endpoints
|
|
||||||
mux.HandleFunc("/v0/servers/server-1", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if err := json.NewEncoder(w).Encode(serverDetail1); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/v0/servers/server-2", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if err := json.NewEncoder(w).Encode(serverDetail2); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
server := httptest.NewServer(mux)
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Test reading from registry root URL (this should trigger pagination)
|
|
||||||
result, err := database.ReadSeedFile(context.Background(), server.URL+"/v0/servers")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Len(t, result, 2)
|
|
||||||
|
|
||||||
// Verify the servers were imported correctly
|
|
||||||
assert.Equal(t, "Test Server 1", result[0].Name)
|
|
||||||
assert.Equal(t, "Test Server 2", result[1].Name)
|
|
||||||
|
|
||||||
// Verify packages were imported with new schema
|
|
||||||
assert.Len(t, result[0].Packages, 1)
|
|
||||||
assert.Equal(t, "npm", result[0].Packages[0].RegistryType)
|
|
||||||
assert.Equal(t, "https://registry.npmjs.org", result[0].Packages[0].RegistryBaseURL)
|
|
||||||
assert.Equal(t, "test-package-1", result[0].Packages[0].Identifier)
|
|
||||||
assert.Len(t, result[1].Packages, 1)
|
|
||||||
assert.Equal(t, "npm", result[1].Packages[0].RegistryType)
|
|
||||||
assert.Equal(t, "https://registry.npmjs.org", result[1].Packages[0].RegistryBaseURL)
|
|
||||||
assert.Equal(t, "test-package-2", result[1].Packages[0].Identifier)
|
|
||||||
|
|
||||||
// Verify metadata was extracted
|
|
||||||
assert.Equal(t, "server-1", result[0].Meta.IOModelContextProtocolRegistry.ID)
|
|
||||||
assert.Equal(t, "server-2", result[1].Meta.IOModelContextProtocolRegistry.ID)
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
||||||
)
|
)
|
||||||
|
|
@ -16,38 +15,17 @@ type MemoryDB struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMemoryDB creates a new instance of the in-memory database
|
func NewMemoryDB() *MemoryDB {
|
||||||
func NewMemoryDB(e map[string]*apiv0.ServerJSON) *MemoryDB {
|
|
||||||
// Convert input ServerJSON entries to have proper metadata
|
// Convert input ServerJSON entries to have proper metadata
|
||||||
serverRecords := make(map[string]*apiv0.ServerJSON)
|
serverRecords := make(map[string]*apiv0.ServerJSON)
|
||||||
for registryID, serverDetail := range e {
|
|
||||||
// Create a copy and add registry metadata
|
|
||||||
now := time.Now()
|
|
||||||
server := *serverDetail // Copy the server
|
|
||||||
if server.Meta == nil {
|
|
||||||
server.Meta = &apiv0.ServerMeta{}
|
|
||||||
}
|
|
||||||
server.Meta.IOModelContextProtocolRegistry = &apiv0.RegistryExtensions{
|
|
||||||
ID: registryID,
|
|
||||||
PublishedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
IsLatest: true,
|
|
||||||
ReleaseDate: now.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
if server.Meta.Publisher == nil {
|
|
||||||
server.Meta.Publisher = make(map[string]interface{})
|
|
||||||
}
|
|
||||||
serverRecords[registryID] = &server
|
|
||||||
}
|
|
||||||
return &MemoryDB{
|
return &MemoryDB{
|
||||||
entries: serverRecords,
|
entries: serverRecords,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:cyclop,gocyclo,gocognit // Complexity from filtering logic is acceptable for memory implementation
|
|
||||||
func (db *MemoryDB) List(
|
func (db *MemoryDB) List(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
filter map[string]any,
|
filter *ServerFilter,
|
||||||
cursor string,
|
cursor string,
|
||||||
limit int,
|
limit int,
|
||||||
) ([]*apiv0.ServerJSON, string, error) {
|
) ([]*apiv0.ServerJSON, string, error) {
|
||||||
|
|
@ -62,73 +40,20 @@ func (db *MemoryDB) List(
|
||||||
db.mu.RLock()
|
db.mu.RLock()
|
||||||
defer db.mu.RUnlock()
|
defer db.mu.RUnlock()
|
||||||
|
|
||||||
// Convert all entries to a slice for pagination, filter by is_latest
|
// Convert all entries to a slice for pagination
|
||||||
var allEntries []*apiv0.ServerJSON
|
var allEntries []*apiv0.ServerJSON
|
||||||
for _, entry := range db.entries {
|
for _, entry := range db.entries {
|
||||||
if entry.Meta != nil && entry.Meta.IOModelContextProtocolRegistry != nil && entry.Meta.IOModelContextProtocolRegistry.IsLatest {
|
allEntries = append(allEntries, entry)
|
||||||
allEntries = append(allEntries, entry)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple filtering implementation
|
// Apply filtering and sorting
|
||||||
var filteredEntries []*apiv0.ServerJSON
|
filteredEntries := db.filterAndSort(allEntries, filter)
|
||||||
for _, entry := range allEntries {
|
|
||||||
include := true
|
|
||||||
|
|
||||||
// Apply filters if any
|
// Find starting point for cursor-based pagination
|
||||||
for key, value := range filter {
|
|
||||||
switch key {
|
|
||||||
case "name":
|
|
||||||
if entry.Name != value.(string) {
|
|
||||||
include = false
|
|
||||||
}
|
|
||||||
case "version":
|
|
||||||
if entry.VersionDetail.Version != value.(string) {
|
|
||||||
include = false
|
|
||||||
}
|
|
||||||
case "status":
|
|
||||||
if string(entry.Status) != value.(string) {
|
|
||||||
include = false
|
|
||||||
}
|
|
||||||
case "remote_url":
|
|
||||||
found := false
|
|
||||||
remoteURL := value.(string)
|
|
||||||
for _, remote := range entry.Remotes {
|
|
||||||
if remote.URL == remoteURL {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
include = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if include {
|
|
||||||
filteredEntries = append(filteredEntries, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort filteredEntries by registry metadata ID for consistent pagination
|
|
||||||
sort.Slice(filteredEntries, func(i, j int) bool {
|
|
||||||
iID := ""
|
|
||||||
jID := ""
|
|
||||||
if filteredEntries[i].Meta != nil && filteredEntries[i].Meta.IOModelContextProtocolRegistry != nil {
|
|
||||||
iID = filteredEntries[i].Meta.IOModelContextProtocolRegistry.ID
|
|
||||||
}
|
|
||||||
if filteredEntries[j].Meta != nil && filteredEntries[j].Meta.IOModelContextProtocolRegistry != nil {
|
|
||||||
jID = filteredEntries[j].Meta.IOModelContextProtocolRegistry.ID
|
|
||||||
}
|
|
||||||
return iID < jID
|
|
||||||
})
|
|
||||||
|
|
||||||
// Find starting point for cursor-based pagination using registry metadata ID
|
|
||||||
startIdx := 0
|
startIdx := 0
|
||||||
if cursor != "" {
|
if cursor != "" {
|
||||||
for i, entry := range filteredEntries {
|
for i, entry := range filteredEntries {
|
||||||
if entry.Meta != nil && entry.Meta.IOModelContextProtocolRegistry != nil &&
|
if db.getRegistryID(entry) == cursor {
|
||||||
entry.Meta.IOModelContextProtocolRegistry.ID == cursor {
|
|
||||||
startIdx = i + 1 // Start after the cursor
|
startIdx = i + 1 // Start after the cursor
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -145,19 +70,15 @@ func (db *MemoryDB) List(
|
||||||
result = []*apiv0.ServerJSON{}
|
result = []*apiv0.ServerJSON{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine next cursor using registry metadata ID
|
// Determine next cursor
|
||||||
nextCursor := ""
|
nextCursor := ""
|
||||||
if endIdx < len(filteredEntries) && len(result) > 0 {
|
if endIdx < len(filteredEntries) && len(result) > 0 {
|
||||||
lastEntry := result[len(result)-1]
|
nextCursor = db.getRegistryID(result[len(result)-1])
|
||||||
if lastEntry.Meta != nil && lastEntry.Meta.IOModelContextProtocolRegistry != nil {
|
|
||||||
nextCursor = lastEntry.Meta.IOModelContextProtocolRegistry.ID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nextCursor, nil
|
return result, nextCursor, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByID retrieves a single ServerRecord by its registry metadata ID
|
|
||||||
func (db *MemoryDB) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error) {
|
func (db *MemoryDB) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error) {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
|
|
@ -176,103 +97,28 @@ func (db *MemoryDB) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON,
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish adds a new server to the database with separated server.json and extensions
|
func (db *MemoryDB) CreateServer(ctx context.Context, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
|
||||||
func (db *MemoryDB) Publish(ctx context.Context, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv0.RegistryExtensions) (*apiv0.ServerJSON, error) {
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract name and version for validation
|
// Get the ID from the registry metadata
|
||||||
name := serverDetail.Name
|
if server.Meta == nil || server.Meta.IOModelContextProtocolRegistry == nil {
|
||||||
if name == "" {
|
return nil, fmt.Errorf("server must have registry metadata with ID")
|
||||||
return nil, fmt.Errorf("name is required in server JSON")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
version := serverDetail.VersionDetail.Version
|
id := server.Meta.IOModelContextProtocolRegistry.ID
|
||||||
if version == "" {
|
|
||||||
return nil, fmt.Errorf("version is required in version_detail")
|
|
||||||
}
|
|
||||||
|
|
||||||
db.mu.Lock()
|
db.mu.Lock()
|
||||||
defer db.mu.Unlock()
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
// Validate repository URL
|
|
||||||
if serverDetail.Repository.URL == "" {
|
|
||||||
return nil, ErrInvalidInput
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create server record with metadata
|
|
||||||
record := serverDetail // Copy the input
|
|
||||||
|
|
||||||
// Initialize meta if not present
|
|
||||||
if record.Meta == nil {
|
|
||||||
record.Meta = &apiv0.ServerMeta{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set registry metadata
|
|
||||||
record.Meta.IOModelContextProtocolRegistry = ®istryMetadata
|
|
||||||
|
|
||||||
// Set publisher extensions if provided
|
|
||||||
if len(publisherExtensions) > 0 {
|
|
||||||
record.Meta.Publisher = publisherExtensions
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the record using registry metadata ID
|
// Store the record using registry metadata ID
|
||||||
db.entries[registryMetadata.ID] = &record
|
db.entries[id] = server
|
||||||
|
|
||||||
return &record, nil
|
return server, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportSeed imports initial data from a seed file into memory database
|
func (db *MemoryDB) UpdateServer(ctx context.Context, id string, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
|
||||||
func (db *MemoryDB) ImportSeed(ctx context.Context, seedFilePath string) error {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// This will need to be updated to work with the new ServerRecord format
|
|
||||||
// Read seed data using the shared ReadSeedFile function
|
|
||||||
seedData, err := ReadSeedFile(ctx, seedFilePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read seed file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock for concurrent access
|
|
||||||
db.mu.Lock()
|
|
||||||
defer db.mu.Unlock()
|
|
||||||
|
|
||||||
// Import each server
|
|
||||||
for _, record := range seedData {
|
|
||||||
// Use the registry metadata ID as the map key
|
|
||||||
if record.Meta != nil && record.Meta.IOModelContextProtocolRegistry != nil {
|
|
||||||
db.entries[record.Meta.IOModelContextProtocolRegistry.ID] = record
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateLatestFlag updates the is_latest flag for a specific server record
|
|
||||||
func (db *MemoryDB) UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
db.mu.Lock()
|
|
||||||
defer db.mu.Unlock()
|
|
||||||
|
|
||||||
if entry, exists := db.entries[id]; exists {
|
|
||||||
if entry.Meta != nil && entry.Meta.IOModelContextProtocolRegistry != nil {
|
|
||||||
entry.Meta.IOModelContextProtocolRegistry.IsLatest = isLatest
|
|
||||||
entry.Meta.IOModelContextProtocolRegistry.UpdatedAt = time.Now()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateServer updates an existing server record with new server details
|
|
||||||
func (db *MemoryDB) UpdateServer(ctx context.Context, id string, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}) (*apiv0.ServerJSON, error) {
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
@ -280,40 +126,75 @@ func (db *MemoryDB) UpdateServer(ctx context.Context, id string, serverDetail ap
|
||||||
db.mu.Lock()
|
db.mu.Lock()
|
||||||
defer db.mu.Unlock()
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
entry, exists := db.entries[id]
|
_, exists := db.entries[id]
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the server details by copying fields from serverDetail
|
// Update the server
|
||||||
*entry = serverDetail
|
db.entries[id] = server
|
||||||
|
|
||||||
// Ensure meta exists and update it
|
|
||||||
if entry.Meta == nil {
|
|
||||||
entry.Meta = &apiv0.ServerMeta{}
|
|
||||||
}
|
|
||||||
if entry.Meta.IOModelContextProtocolRegistry != nil {
|
|
||||||
entry.Meta.IOModelContextProtocolRegistry.UpdatedAt = time.Now()
|
|
||||||
}
|
|
||||||
if len(publisherExtensions) > 0 {
|
|
||||||
entry.Meta.Publisher = publisherExtensions
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the updated record
|
// Return the updated record
|
||||||
return entry, nil
|
return server, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the database connection
|
|
||||||
// For an in-memory database, this is a no-op
|
// For an in-memory database, this is a no-op
|
||||||
func (db *MemoryDB) Close() error {
|
func (db *MemoryDB) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection returns information about the database connection
|
// filterAndSort applies filtering and sorting to the entries
|
||||||
func (db *MemoryDB) Connection() *ConnectionInfo {
|
func (db *MemoryDB) filterAndSort(allEntries []*apiv0.ServerJSON, filter *ServerFilter) []*apiv0.ServerJSON {
|
||||||
return &ConnectionInfo{
|
// Apply filtering
|
||||||
Type: ConnectionTypeMemory,
|
var filteredEntries []*apiv0.ServerJSON
|
||||||
IsConnected: true, // Memory DB is always connected
|
for _, entry := range allEntries {
|
||||||
Raw: db.entries,
|
if db.matchesFilter(entry, filter) {
|
||||||
|
filteredEntries = append(filteredEntries, entry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by registry metadata ID for consistent pagination
|
||||||
|
sort.Slice(filteredEntries, func(i, j int) bool {
|
||||||
|
iID := db.getRegistryID(filteredEntries[i])
|
||||||
|
jID := db.getRegistryID(filteredEntries[j])
|
||||||
|
return iID < jID
|
||||||
|
})
|
||||||
|
|
||||||
|
return filteredEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesFilter checks if an entry matches the provided filter
|
||||||
|
func (db *MemoryDB) matchesFilter(entry *apiv0.ServerJSON, filter *ServerFilter) bool {
|
||||||
|
if filter == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check name filter
|
||||||
|
if filter.Name != nil && entry.Name != *filter.Name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check remote URL filter
|
||||||
|
if filter.RemoteURL != nil {
|
||||||
|
found := false
|
||||||
|
for _, remote := range entry.Remotes {
|
||||||
|
if remote.URL == *filter.RemoteURL {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRegistryID safely extracts the registry ID from an entry
|
||||||
|
func (db *MemoryDB) getRegistryID(entry *apiv0.ServerJSON) string {
|
||||||
|
if entry.Meta != nil && entry.Meta.IOModelContextProtocolRegistry != nil {
|
||||||
|
return entry.Meta.IOModelContextProtocolRegistry.ID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
@ -42,10 +40,9 @@ func NewPostgreSQL(ctx context.Context, connectionURI string) (*PostgreSQL, erro
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List retrieves ServerJSON entries with optional filtering and pagination
|
|
||||||
func (db *PostgreSQL) List(
|
func (db *PostgreSQL) List(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
filter map[string]any,
|
filter *ServerFilter,
|
||||||
cursor string,
|
cursor string,
|
||||||
limit int,
|
limit int,
|
||||||
) ([]*apiv0.ServerJSON, string, error) {
|
) ([]*apiv0.ServerJSON, string, error) {
|
||||||
|
|
@ -57,30 +54,21 @@ func (db *PostgreSQL) List(
|
||||||
return nil, "", ctx.Err()
|
return nil, "", ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build WHERE clause for filtering - always filter by is_latest = true by default
|
// Build WHERE clause for filtering
|
||||||
var whereConditions []string
|
var whereConditions []string
|
||||||
whereConditions = append(whereConditions, "(value->'_meta'->'io.modelcontextprotocol.registry'->>'is_latest')::boolean = true")
|
|
||||||
args := []any{}
|
args := []any{}
|
||||||
argIndex := 1
|
argIndex := 1
|
||||||
|
|
||||||
// Add filters using JSON operators
|
// Add filters using JSON operators
|
||||||
for k, v := range filter {
|
if filter != nil {
|
||||||
switch k {
|
if filter.Name != nil {
|
||||||
case "name":
|
|
||||||
whereConditions = append(whereConditions, fmt.Sprintf("value->>'name' = $%d", argIndex))
|
whereConditions = append(whereConditions, fmt.Sprintf("value->>'name' = $%d", argIndex))
|
||||||
args = append(args, v)
|
args = append(args, *filter.Name)
|
||||||
argIndex++
|
argIndex++
|
||||||
case "version":
|
}
|
||||||
whereConditions = append(whereConditions, fmt.Sprintf("value->'version_detail'->>'version' = $%d", argIndex))
|
if filter.RemoteURL != nil {
|
||||||
args = append(args, v)
|
|
||||||
argIndex++
|
|
||||||
case "status":
|
|
||||||
whereConditions = append(whereConditions, fmt.Sprintf("value->>'status' = $%d", argIndex))
|
|
||||||
args = append(args, v)
|
|
||||||
argIndex++
|
|
||||||
case "remote_url":
|
|
||||||
whereConditions = append(whereConditions, fmt.Sprintf("EXISTS (SELECT 1 FROM jsonb_array_elements(value->'remotes') AS remote WHERE remote->>'url' = $%d)", argIndex))
|
whereConditions = append(whereConditions, fmt.Sprintf("EXISTS (SELECT 1 FROM jsonb_array_elements(value->'remotes') AS remote WHERE remote->>'url' = $%d)", argIndex))
|
||||||
args = append(args, v)
|
args = append(args, *filter.RemoteURL)
|
||||||
argIndex++
|
argIndex++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +139,6 @@ func (db *PostgreSQL) List(
|
||||||
return results, nextCursor, nil
|
return results, nextCursor, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByID retrieves a single ServerJSON by its registry metadata ID
|
|
||||||
func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error) {
|
func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error) {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
|
|
@ -183,27 +170,18 @@ func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON
|
||||||
return &serverJSON, nil
|
return &serverJSON, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish adds a new server to the simple servers table
|
// CreateServer adds a new server to the database
|
||||||
func (db *PostgreSQL) Publish(ctx context.Context, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv0.RegistryExtensions) (*apiv0.ServerJSON, error) {
|
func (db *PostgreSQL) CreateServer(ctx context.Context, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create complete server with metadata
|
// Get the ID from the registry metadata
|
||||||
server := serverDetail // Copy the input
|
if server.Meta == nil || server.Meta.IOModelContextProtocolRegistry == nil {
|
||||||
|
return nil, fmt.Errorf("server must have registry metadata with ID")
|
||||||
// Initialize meta if not present
|
|
||||||
if server.Meta == nil {
|
|
||||||
server.Meta = &apiv0.ServerMeta{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set registry metadata
|
id := server.Meta.IOModelContextProtocolRegistry.ID
|
||||||
server.Meta.IOModelContextProtocolRegistry = ®istryMetadata
|
|
||||||
|
|
||||||
// Set publisher extensions if provided
|
|
||||||
if len(publisherExtensions) > 0 {
|
|
||||||
server.Meta.Publisher = publisherExtensions
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marshal the complete server to JSONB
|
// Marshal the complete server to JSONB
|
||||||
valueJSON, err := json.Marshal(server)
|
valueJSON, err := json.Marshal(server)
|
||||||
|
|
@ -217,130 +195,22 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail apiv0.ServerJSON
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = db.conn.Exec(ctx, query, registryMetadata.ID, valueJSON)
|
_, err = db.conn.Exec(ctx, query, id, valueJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to insert server: %w", err)
|
return nil, fmt.Errorf("failed to insert server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &server, nil
|
return server, nil
|
||||||
}
|
|
||||||
|
|
||||||
// ImportSeed imports initial data from a seed file into PostgreSQL
|
|
||||||
func (db *PostgreSQL) ImportSeed(ctx context.Context, seedFilePath string) error {
|
|
||||||
// Read seed data using the shared ReadSeedFile function
|
|
||||||
seedData, err := ReadSeedFile(ctx, seedFilePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read seed file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start a transaction for batch import
|
|
||||||
tx, err := db.conn.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to start transaction: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if rollbackErr := tx.Rollback(ctx); rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) {
|
|
||||||
log.Printf("Failed to rollback transaction: %v", rollbackErr)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Import each server
|
|
||||||
for _, record := range seedData {
|
|
||||||
// Marshal the server to JSONB
|
|
||||||
valueJSON, err := json.Marshal(record)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal server JSON for import: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get registry ID from metadata
|
|
||||||
var registryID string
|
|
||||||
if record.Meta != nil && record.Meta.IOModelContextProtocolRegistry != nil {
|
|
||||||
registryID = record.Meta.IOModelContextProtocolRegistry.ID
|
|
||||||
} else {
|
|
||||||
registryID = uuid.New().String() // Generate ID if not present
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert into simple servers table
|
|
||||||
_, err = tx.Exec(ctx, "INSERT INTO servers (id, value) VALUES ($1, $2)", registryID, valueJSON)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to import server %s: %w", record.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit the transaction
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return fmt.Errorf("failed to commit seed import transaction: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateLatestFlag updates the is_latest flag for a specific server record
|
|
||||||
func (db *PostgreSQL) UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
UPDATE servers
|
|
||||||
SET value = jsonb_set(
|
|
||||||
jsonb_set(
|
|
||||||
value,
|
|
||||||
'{_meta,io.modelcontextprotocol.registry,is_latest}',
|
|
||||||
$1::jsonb
|
|
||||||
),
|
|
||||||
'{_meta,io.modelcontextprotocol.registry,updated_at}',
|
|
||||||
$2::jsonb
|
|
||||||
)
|
|
||||||
WHERE (value->'_meta'->'io.modelcontextprotocol.registry'->>'id') = $3
|
|
||||||
`
|
|
||||||
|
|
||||||
result, err := db.conn.Exec(ctx, query,
|
|
||||||
fmt.Sprintf("%t", isLatest),
|
|
||||||
fmt.Sprintf("\"%s\"", time.Now().Format(time.RFC3339)),
|
|
||||||
id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to update latest flag: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.RowsAffected() == 0 {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateServer updates an existing server record with new server details
|
// UpdateServer updates an existing server record with new server details
|
||||||
func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}) (*apiv0.ServerJSON, error) {
|
func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the existing server to preserve registry metadata
|
|
||||||
existingServer, err := db.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the server details while preserving registry metadata
|
|
||||||
updatedServer := serverDetail
|
|
||||||
if updatedServer.Meta == nil {
|
|
||||||
updatedServer.Meta = &apiv0.ServerMeta{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve existing registry metadata but update timestamp
|
|
||||||
if existingServer.Meta != nil && existingServer.Meta.IOModelContextProtocolRegistry != nil {
|
|
||||||
updatedServer.Meta.IOModelContextProtocolRegistry = existingServer.Meta.IOModelContextProtocolRegistry
|
|
||||||
updatedServer.Meta.IOModelContextProtocolRegistry.UpdatedAt = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update publisher extensions if provided
|
|
||||||
if len(publisherExtensions) > 0 {
|
|
||||||
updatedServer.Meta.Publisher = publisherExtensions
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marshal updated server
|
// Marshal updated server
|
||||||
valueJSON, err := json.Marshal(updatedServer)
|
valueJSON, err := json.Marshal(server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal updated server: %w", err)
|
return nil, fmt.Errorf("failed to marshal updated server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -361,27 +231,10 @@ func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, serverDetail
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return &updatedServer, nil
|
return server, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the database connection
|
// Close closes the database connection
|
||||||
func (db *PostgreSQL) Close() error {
|
func (db *PostgreSQL) Close() error {
|
||||||
return db.conn.Close(context.Background())
|
return db.conn.Close(context.Background())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection returns information about the database connection
|
|
||||||
func (db *PostgreSQL) Connection() *ConnectionInfo {
|
|
||||||
isConnected := false
|
|
||||||
if db.conn != nil {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
err := db.conn.Ping(ctx)
|
|
||||||
isConnected = (err == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ConnectionInfo{
|
|
||||||
Type: ConnectionTypePostgreSQL,
|
|
||||||
IsConnected: isConnected,
|
|
||||||
Raw: db.conn,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
184
internal/importer/importer.go
Normal file
184
internal/importer/importer.go
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/database"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/validators"
|
||||||
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service handles importing seed data into the registry
|
||||||
|
type Service struct {
|
||||||
|
db database.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new importer service
|
||||||
|
func NewService(db database.Database) *Service {
|
||||||
|
return &Service{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportFromPath imports seed data from various sources:
|
||||||
|
// 1. Local file paths (*.json files) - expects extension wrapper format
|
||||||
|
// 2. Direct HTTP URLs to seed.json files - expects extension wrapper format
|
||||||
|
// 3. Registry root URLs (automatically appends /v0/servers and paginates)
|
||||||
|
func (s *Service) ImportFromPath(ctx context.Context, path string) error {
|
||||||
|
servers, err := readSeedFile(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read seed data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import each server using CreateServer
|
||||||
|
for _, server := range servers {
|
||||||
|
_, err := s.db.CreateServer(ctx, server)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to import server %s: %w", server.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// readSeedFile reads seed data from various sources
|
||||||
|
func readSeedFile(ctx context.Context, path string) ([]*apiv0.ServerJSON, error) {
|
||||||
|
var data []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||||
|
// Handle HTTP URLs
|
||||||
|
if strings.HasSuffix(path, "/v0/servers") || strings.Contains(path, "/v0/servers") {
|
||||||
|
// This is a registry API endpoint - fetch paginated data
|
||||||
|
return fetchFromRegistryAPI(ctx, path)
|
||||||
|
}
|
||||||
|
// This is a direct file URL
|
||||||
|
data, err = fetchFromHTTP(ctx, path)
|
||||||
|
} else {
|
||||||
|
// Handle local file paths
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read seed data from %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse extension wrapper format (only supported format)
|
||||||
|
var serverResponses []apiv0.ServerJSON
|
||||||
|
if err := json.Unmarshal(data, &serverResponses); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse seed data as extension wrapper format: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(serverResponses) == 0 {
|
||||||
|
return []*apiv0.ServerJSON{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate servers and collect warnings instead of failing the whole batch
|
||||||
|
var validRecords []*apiv0.ServerJSON
|
||||||
|
var invalidServers []string
|
||||||
|
var validationFailures []string
|
||||||
|
|
||||||
|
for _, response := range serverResponses {
|
||||||
|
if err := validators.ValidateServerJSON(&response); err != nil {
|
||||||
|
// Log warning and track invalid server instead of failing
|
||||||
|
invalidServers = append(invalidServers, response.Name)
|
||||||
|
validationFailures = append(validationFailures, fmt.Sprintf("Server '%s': %v", response.Name, err))
|
||||||
|
log.Printf("Warning: Skipping invalid server '%s': %v", response.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert valid ServerJSON to ServerRecord
|
||||||
|
record := convertServerResponseToRecord(response)
|
||||||
|
validRecords = append(validRecords, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print summary of validation results
|
||||||
|
if len(invalidServers) > 0 {
|
||||||
|
log.Printf("Import summary: %d valid servers imported, %d invalid servers skipped", len(validRecords), len(invalidServers))
|
||||||
|
log.Printf("Invalid servers: %v", invalidServers)
|
||||||
|
for _, failure := range validationFailures {
|
||||||
|
log.Printf(" - %s", failure)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("Import summary: All %d servers imported successfully", len(validRecords))
|
||||||
|
}
|
||||||
|
|
||||||
|
return validRecords, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFromHTTP(ctx context.Context, url string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch from HTTP: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP request failed with status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFromRegistryAPI(ctx context.Context, baseURL string) ([]*apiv0.ServerJSON, error) {
|
||||||
|
var allRecords []*apiv0.ServerJSON
|
||||||
|
cursor := ""
|
||||||
|
|
||||||
|
for {
|
||||||
|
url := baseURL
|
||||||
|
if cursor != "" {
|
||||||
|
if strings.Contains(url, "?") {
|
||||||
|
url += "&cursor=" + cursor
|
||||||
|
} else {
|
||||||
|
url += "?cursor=" + cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := fetchFromHTTP(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch page from registry API: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Servers []apiv0.ServerJSON `json:"servers"`
|
||||||
|
Metadata *struct {
|
||||||
|
NextCursor string `json:"next_cursor,omitempty"`
|
||||||
|
} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse registry API response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert and add servers
|
||||||
|
for _, serverResponse := range response.Servers {
|
||||||
|
record := convertServerResponseToRecord(serverResponse)
|
||||||
|
allRecords = append(allRecords, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's a next page
|
||||||
|
if response.Metadata == nil || response.Metadata.NextCursor == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cursor = response.Metadata.NextCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
return allRecords, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertServerResponseToRecord(response apiv0.ServerJSON) *apiv0.ServerJSON {
|
||||||
|
// The response is already in the correct flattened format
|
||||||
|
// Just return a pointer to it
|
||||||
|
return &response
|
||||||
|
}
|
||||||
201
internal/importer/importer_test.go
Normal file
201
internal/importer/importer_test.go
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
package importer_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/database"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/importer"
|
||||||
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
||||||
|
"github.com/modelcontextprotocol/registry/pkg/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestImportService_LocalFile(t *testing.T) {
|
||||||
|
// Create a temporary seed file
|
||||||
|
tempFile := "/tmp/test_import_seed.json"
|
||||||
|
seedData := []apiv0.ServerJSON{
|
||||||
|
{
|
||||||
|
Name: "io.github.test/test-server-1",
|
||||||
|
Description: "Test server 1",
|
||||||
|
Repository: model.Repository{
|
||||||
|
URL: "https://github.com/test/repo1",
|
||||||
|
Source: "github",
|
||||||
|
ID: "123",
|
||||||
|
},
|
||||||
|
VersionDetail: model.VersionDetail{
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Meta: &apiv0.ServerMeta{
|
||||||
|
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
||||||
|
ID: "test-id-1",
|
||||||
|
PublishedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
IsLatest: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(seedData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.WriteFile(tempFile, jsonData, 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tempFile)
|
||||||
|
|
||||||
|
memDB := database.NewMemoryDB()
|
||||||
|
|
||||||
|
// Create importer service and test import
|
||||||
|
service := importer.NewService(memDB)
|
||||||
|
err = service.ImportFromPath(context.Background(), tempFile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the server was imported
|
||||||
|
servers, _, err := memDB.List(context.Background(), nil, "", 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, servers, 1)
|
||||||
|
assert.Equal(t, "io.github.test/test-server-1", servers[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportService_HTTPFile(t *testing.T) {
|
||||||
|
// Create a test HTTP server
|
||||||
|
seedData := []apiv0.ServerJSON{
|
||||||
|
{
|
||||||
|
Name: "io.github.test/http-test-server",
|
||||||
|
Description: "HTTP test server",
|
||||||
|
Repository: model.Repository{
|
||||||
|
URL: "https://github.com/test/repo1",
|
||||||
|
Source: "github",
|
||||||
|
ID: "123",
|
||||||
|
},
|
||||||
|
VersionDetail: model.VersionDetail{
|
||||||
|
Version: "2.0.0",
|
||||||
|
},
|
||||||
|
Meta: &apiv0.ServerMeta{
|
||||||
|
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
||||||
|
ID: "test-id-2",
|
||||||
|
PublishedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
IsLatest: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(seedData)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
memDB := database.NewMemoryDB()
|
||||||
|
|
||||||
|
// Create importer service and test import
|
||||||
|
service := importer.NewService(memDB)
|
||||||
|
err := service.ImportFromPath(context.Background(), server.URL+"/seed.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the server was imported
|
||||||
|
servers, _, err := memDB.List(context.Background(), nil, "", 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, servers, 1)
|
||||||
|
assert.Equal(t, "io.github.test/http-test-server", servers[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportService_RegistryAPI(t *testing.T) {
|
||||||
|
// Create test data
|
||||||
|
testServers := []apiv0.ServerJSON{
|
||||||
|
{
|
||||||
|
Name: "io.github.test/api-server-1",
|
||||||
|
Description: "API server 1",
|
||||||
|
Repository: model.Repository{
|
||||||
|
URL: "https://github.com/test/repo1",
|
||||||
|
Source: "github",
|
||||||
|
ID: "123",
|
||||||
|
},
|
||||||
|
VersionDetail: model.VersionDetail{
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Meta: &apiv0.ServerMeta{
|
||||||
|
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
||||||
|
ID: "api-test-id-1",
|
||||||
|
PublishedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
IsLatest: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "io.github.test/api-server-2",
|
||||||
|
Description: "API server 2",
|
||||||
|
Repository: model.Repository{
|
||||||
|
URL: "https://github.com/test/repo2",
|
||||||
|
Source: "github",
|
||||||
|
ID: "456",
|
||||||
|
},
|
||||||
|
VersionDetail: model.VersionDetail{
|
||||||
|
Version: "2.0.0",
|
||||||
|
},
|
||||||
|
Meta: &apiv0.ServerMeta{
|
||||||
|
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
|
||||||
|
ID: "api-test-id-2",
|
||||||
|
PublishedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
IsLatest: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test server that responds with paginated results
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cursor := r.URL.Query().Get("cursor")
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Servers []apiv0.ServerJSON `json:"servers"`
|
||||||
|
Metadata *struct {
|
||||||
|
NextCursor string `json:"next_cursor,omitempty"`
|
||||||
|
} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cursor {
|
||||||
|
case "":
|
||||||
|
// First page
|
||||||
|
response.Servers = []apiv0.ServerJSON{testServers[0]}
|
||||||
|
response.Metadata = &struct {
|
||||||
|
NextCursor string `json:"next_cursor,omitempty"`
|
||||||
|
}{NextCursor: "page2"}
|
||||||
|
case "page2":
|
||||||
|
// Second page
|
||||||
|
response.Servers = []apiv0.ServerJSON{testServers[1]}
|
||||||
|
// No next cursor means end of results
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(response)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
memDB := database.NewMemoryDB()
|
||||||
|
|
||||||
|
// Create importer service and test import
|
||||||
|
service := importer.NewService(memDB)
|
||||||
|
err := service.ImportFromPath(context.Background(), server.URL+"/v0/servers")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify both servers were imported
|
||||||
|
servers, _, err := memDB.List(context.Background(), nil, "", 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, servers, 2)
|
||||||
|
|
||||||
|
names := []string{servers[0].Name, servers[1].Name}
|
||||||
|
assert.Contains(t, names, "io.github.test/api-server-1")
|
||||||
|
assert.Contains(t, names, "io.github.test/api-server-2")
|
||||||
|
}
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/modelcontextprotocol/registry/internal/database"
|
|
||||||
"github.com/modelcontextprotocol/registry/internal/validators"
|
|
||||||
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
|
||||||
"github.com/modelcontextprotocol/registry/pkg/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeRegistryService implements RegistryService interface with an in-memory database
|
|
||||||
type fakeRegistryService struct {
|
|
||||||
db *database.MemoryDB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFakeRegistryService creates a new fake registry service with pre-populated data
|
|
||||||
func NewFakeRegistryService() RegistryService {
|
|
||||||
// Sample registry entries with updated model structure using ServerJSON
|
|
||||||
serverDetails := []*apiv0.ServerJSON{
|
|
||||||
{
|
|
||||||
Name: "bluegreen/mcp-server",
|
|
||||||
Description: "A dummy MCP registry for testing",
|
|
||||||
Repository: model.Repository{
|
|
||||||
URL: "https://github.com/example/mcp-1",
|
|
||||||
Source: "github",
|
|
||||||
ID: "example/mcp-1",
|
|
||||||
},
|
|
||||||
VersionDetail: model.VersionDetail{
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "orangepurple/mcp-server",
|
|
||||||
Description: "Another dummy MCP registry for testing",
|
|
||||||
Repository: model.Repository{
|
|
||||||
URL: "https://github.com/example/mcp-2",
|
|
||||||
Source: "github",
|
|
||||||
ID: "example/mcp-2",
|
|
||||||
},
|
|
||||||
VersionDetail: model.VersionDetail{
|
|
||||||
Version: "0.9.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "greenyellow/mcp-server",
|
|
||||||
Description: "Yet another dummy MCP registry for testing",
|
|
||||||
Repository: model.Repository{
|
|
||||||
URL: "https://github.com/example/mcp-3",
|
|
||||||
Source: "github",
|
|
||||||
ID: "example/mcp-3",
|
|
||||||
},
|
|
||||||
VersionDetail: model.VersionDetail{
|
|
||||||
Version: "0.9.5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new in-memory database using registry metadata IDs
|
|
||||||
serverDetailMap := make(map[string]*apiv0.ServerJSON)
|
|
||||||
for _, entry := range serverDetails {
|
|
||||||
registryID := uuid.New().String() // Generate registry metadata ID
|
|
||||||
serverDetailMap[registryID] = entry
|
|
||||||
}
|
|
||||||
memDB := database.NewMemoryDB(serverDetailMap)
|
|
||||||
return &fakeRegistryService{
|
|
||||||
db: memDB,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List retrieves servers with extension wrapper format
|
|
||||||
func (s *fakeRegistryService) List(cursor string, limit int) ([]apiv0.ServerJSON, string, error) {
|
|
||||||
// Create a timeout context for the database operation
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Use the database's List method to get ServerRecord entries
|
|
||||||
serverRecords, nextCursor, err := s.db.List(ctx, nil, cursor, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return ServerRecords directly (they're now the same as ServerResponse)
|
|
||||||
result := make([]apiv0.ServerJSON, len(serverRecords))
|
|
||||||
for i, record := range serverRecords {
|
|
||||||
result[i] = *record
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nextCursor, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByID retrieves a specific server by its registry metadata ID in extension wrapper format
|
|
||||||
func (s *fakeRegistryService) GetByID(id string) (*apiv0.ServerJSON, error) {
|
|
||||||
// Create a timeout context for the database operation
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Use the database's GetByID method to retrieve the server record
|
|
||||||
serverRecord, err := s.db.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return ServerRecord directly (it's now the same as ServerResponse)
|
|
||||||
return serverRecord, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish publishes a server with separated extensions
|
|
||||||
func (s *fakeRegistryService) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
|
|
||||||
// Create a timeout context for the database operation
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Validate the request
|
|
||||||
if err := validators.ValidatePublishRequest(req); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract publisher extensions from _meta.publisher
|
|
||||||
publisherExtensions := make(map[string]interface{})
|
|
||||||
if req.Meta != nil && req.Meta.Publisher != nil {
|
|
||||||
publisherExtensions = req.Meta.Publisher
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create registry metadata for fake service (always marks as latest)
|
|
||||||
now := time.Now()
|
|
||||||
registryMetadata := apiv0.RegistryExtensions{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
PublishedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
IsLatest: true,
|
|
||||||
ReleaseDate: now.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish to database
|
|
||||||
serverRecord, err := s.db.Publish(ctx, req, publisherExtensions, registryMetadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return ServerRecord directly (it's now the same as ServerResponse)
|
|
||||||
return serverRecord, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EditServer updates an existing server with new details (admin operation)
|
|
||||||
func (s *fakeRegistryService) EditServer(id string, req apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Validate the request
|
|
||||||
if err := validators.ValidatePublishRequest(req); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract publisher extensions from _meta.publisher
|
|
||||||
publisherExtensions := make(map[string]interface{})
|
|
||||||
if req.Meta != nil && req.Meta.Publisher != nil {
|
|
||||||
publisherExtensions = req.Meta.Publisher
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update server in database
|
|
||||||
serverRecord, err := s.db.UpdateServer(ctx, id, req, publisherExtensions)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return ServerRecord directly (it's now the same as ServerResponse)
|
|
||||||
return serverRecord, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the in-memory database connection
|
|
||||||
func (s *fakeRegistryService) Close() error {
|
|
||||||
return s.db.Close()
|
|
||||||
}
|
|
||||||
|
|
@ -37,7 +37,7 @@ func (s *registryServiceImpl) List(cursor string, limit int) ([]apiv0.ServerJSON
|
||||||
limit = 30
|
limit = 30
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the database's List method with pagination
|
// Use the database's ListServers method with pagination
|
||||||
serverRecords, nextCursor, err := s.db.List(ctx, nil, cursor, limit)
|
serverRecords, nextCursor, err := s.db.List(ctx, nil, cursor, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
|
|
@ -58,7 +58,6 @@ func (s *registryServiceImpl) GetByID(id string) (*apiv0.ServerJSON, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Use the database's GetByID method to retrieve the server record
|
|
||||||
serverRecord, err := s.db.GetByID(ctx, id)
|
serverRecord, err := s.db.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -87,7 +86,8 @@ func (s *registryServiceImpl) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON,
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
existingServerVersions, _, err := s.db.List(ctx, map[string]any{"name": serverJSON.Name}, "", maxServerVersionsPerServer)
|
filter := &database.ServerFilter{Name: &serverJSON.Name}
|
||||||
|
existingServerVersions, _, err := s.db.List(ctx, filter, "", maxServerVersionsPerServer)
|
||||||
if err != nil && !errors.Is(err, database.ErrNotFound) {
|
if err != nil && !errors.Is(err, database.ErrNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -121,8 +121,16 @@ func (s *registryServiceImpl) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON,
|
||||||
) > 0
|
) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create registry metadata with service-determined values
|
// Create complete server with metadata
|
||||||
registryMetadata := apiv0.RegistryExtensions{
|
server := serverJSON // Copy the input
|
||||||
|
|
||||||
|
// Initialize meta if not present
|
||||||
|
if server.Meta == nil {
|
||||||
|
server.Meta = &apiv0.ServerMeta{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set registry metadata
|
||||||
|
server.Meta.IOModelContextProtocolRegistry = &apiv0.RegistryExtensions{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
PublishedAt: publishTime,
|
PublishedAt: publishTime,
|
||||||
UpdatedAt: publishTime,
|
UpdatedAt: publishTime,
|
||||||
|
|
@ -130,14 +138,8 @@ func (s *registryServiceImpl) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON,
|
||||||
ReleaseDate: publishTime.Format(time.RFC3339),
|
ReleaseDate: publishTime.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract publisher extensions from _meta.publisher
|
// Create server in database
|
||||||
publisherExtensions := make(map[string]interface{})
|
serverRecord, err := s.db.CreateServer(ctx, &server)
|
||||||
if serverJSON.Meta != nil && serverJSON.Meta.Publisher != nil {
|
|
||||||
publisherExtensions = serverJSON.Meta.Publisher
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish to database with the registry metadata (still using old format internally)
|
|
||||||
serverRecord, err := s.db.Publish(ctx, serverJSON, publisherExtensions, registryMetadata)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +151,10 @@ func (s *registryServiceImpl) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON,
|
||||||
existingLatestID = existingLatest.Meta.IOModelContextProtocolRegistry.ID
|
existingLatestID = existingLatest.Meta.IOModelContextProtocolRegistry.ID
|
||||||
}
|
}
|
||||||
if existingLatestID != "" {
|
if existingLatestID != "" {
|
||||||
if err := s.db.UpdateLatestFlag(ctx, existingLatestID, false); err != nil {
|
// Update the existing server to set is_latest = false
|
||||||
|
existingLatest.Meta.IOModelContextProtocolRegistry.IsLatest = false
|
||||||
|
existingLatest.Meta.IOModelContextProtocolRegistry.UpdatedAt = time.Now()
|
||||||
|
if _, err := s.db.UpdateServer(ctx, existingLatestID, existingLatest); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -164,9 +169,7 @@ func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context,
|
||||||
// Check each remote URL in the new server for conflicts
|
// Check each remote URL in the new server for conflicts
|
||||||
for _, remote := range serverDetail.Remotes {
|
for _, remote := range serverDetail.Remotes {
|
||||||
// Use filter to find servers with this remote URL
|
// Use filter to find servers with this remote URL
|
||||||
filter := map[string]any{
|
filter := &database.ServerFilter{RemoteURL: &remote.URL}
|
||||||
"remote_url": remote.URL,
|
|
||||||
}
|
|
||||||
|
|
||||||
conflictingServers, _, err := s.db.List(ctx, filter, "", 1000)
|
conflictingServers, _, err := s.db.List(ctx, filter, "", 1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -187,8 +190,8 @@ func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context,
|
||||||
// getCurrentLatestVersion finds the current latest version from existing server versions
|
// getCurrentLatestVersion finds the current latest version from existing server versions
|
||||||
func (s *registryServiceImpl) getCurrentLatestVersion(existingServerVersions []*apiv0.ServerJSON) *apiv0.ServerJSON {
|
func (s *registryServiceImpl) getCurrentLatestVersion(existingServerVersions []*apiv0.ServerJSON) *apiv0.ServerJSON {
|
||||||
for _, server := range existingServerVersions {
|
for _, server := range existingServerVersions {
|
||||||
if server.Meta != nil && server.Meta.IOModelContextProtocolRegistry != nil &&
|
if server.Meta != nil && server.Meta.IOModelContextProtocolRegistry != nil &&
|
||||||
server.Meta.IOModelContextProtocolRegistry.IsLatest {
|
server.Meta.IOModelContextProtocolRegistry.IsLatest {
|
||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,14 +215,8 @@ func (s *registryServiceImpl) EditServer(id string, req apiv0.ServerJSON) (*apiv
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract publisher extensions from _meta.publisher
|
|
||||||
publisherExtensions := make(map[string]interface{})
|
|
||||||
if serverJSON.Meta != nil && serverJSON.Meta.Publisher != nil {
|
|
||||||
publisherExtensions = serverJSON.Meta.Publisher
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update server in database
|
// Update server in database
|
||||||
serverRecord, err := s.db.UpdateServer(ctx, id, serverJSON, publisherExtensions)
|
serverRecord, err := s.db.UpdateServer(ctx, id, &serverJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -227,4 +224,3 @@ func (s *registryServiceImpl) EditServer(id string, req apiv0.ServerJSON) (*apiv
|
||||||
// Return the server record directly
|
// Return the server record directly
|
||||||
return serverRecord, nil
|
return serverRecord, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,16 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
memDB := database.NewMemoryDB(existingServers)
|
memDB := database.NewMemoryDB()
|
||||||
service := NewRegistryServiceWithDB(memDB)
|
service := NewRegistryServiceWithDB(memDB)
|
||||||
|
|
||||||
|
for _, server := range existingServers {
|
||||||
|
_, err := service.Publish(*server)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to publish server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
serverDetail apiv0.ServerJSON
|
serverDetail apiv0.ServerJSON
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ const (
|
||||||
StatusDeleted Status = "deleted"
|
StatusDeleted Status = "deleted"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
// Package represents a package configuration
|
// Package represents a package configuration
|
||||||
type Package struct {
|
type Package struct {
|
||||||
// RegistryType indicates how to download packages (e.g., "npm", "pypi", "oci", "mcpb")
|
// RegistryType indicates how to download packages (e.g., "npm", "pypi", "oci", "mcpb")
|
||||||
|
|
@ -92,5 +91,5 @@ type Argument struct {
|
||||||
|
|
||||||
// VersionDetail represents the version details of a server (pure MCP spec, no registry metadata)
|
// VersionDetail represents the version details of a server (pure MCP spec, no registry metadata)
|
||||||
type VersionDetail struct {
|
type VersionDetail struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version" minLength:"1"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,7 @@ func findServerIDByName(serverName string) (string, error) {
|
||||||
|
|
||||||
// Find the server with matching name
|
// Find the server with matching name
|
||||||
for _, server := range serverList.Servers {
|
for _, server := range serverList.Servers {
|
||||||
if server.Name == serverName {
|
if server.Name == serverName && server.Meta.IOModelContextProtocolRegistry.IsLatest {
|
||||||
return server.Meta.IOModelContextProtocolRegistry.ID, nil
|
return server.Meta.IOModelContextProtocolRegistry.ID, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue