diff --git a/cmd/registry/main.go b/cmd/registry/main.go index 1412ef2..ba60dc2 100644 --- a/cmd/registry/main.go +++ b/cmd/registry/main.go @@ -14,9 +14,9 @@ import ( "github.com/modelcontextprotocol/registry/internal/api" "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/database" - "github.com/modelcontextprotocol/registry/internal/model" "github.com/modelcontextprotocol/registry/internal/service" "github.com/modelcontextprotocol/registry/internal/telemetry" + "github.com/modelcontextprotocol/registry/pkg/model" ) func main() { @@ -46,7 +46,7 @@ func main() { // Initialize services based on environment switch cfg.DatabaseType { case config.DatabaseTypeMemory: - db = database.NewMemoryDB(map[string]*model.ServerDetail{}) + db = database.NewMemoryDB(map[string]*model.ServerJSON{}) registryService = service.NewRegistryServiceWithDB(db) case config.DatabaseTypePostgreSQL: // Use PostgreSQL for real registry service diff --git a/internal/api/handlers/v0/auth/dns.go b/internal/api/handlers/v0/auth/dns.go index 74936ed..991f513 100644 --- a/internal/api/handlers/v0/auth/dns.go +++ b/internal/api/handlers/v0/auth/dns.go @@ -16,7 +16,6 @@ import ( v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" ) // DNSTokenExchangeInput represents the input for DNS-based authentication @@ -147,7 +146,7 @@ func (h *DNSAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, s // Create JWT claims jwtClaims := auth.JWTClaims{ - AuthMethod: model.AuthMethodDNS, + AuthMethod: auth.MethodDNS, AuthMethodSubject: domain, Permissions: permissions, } diff --git a/internal/api/handlers/v0/auth/dns_test.go b/internal/api/handlers/v0/auth/dns_test.go index cadbd13..e9c10d5 100644 --- a/internal/api/handlers/v0/auth/dns_test.go +++ b/internal/api/handlers/v0/auth/dns_test.go @@ -16,7 +16,6 @@ import ( "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth" intauth "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" ) // MockDNSResolver for testing @@ -174,7 +173,7 @@ func TestDNSAuthHandler_ExchangeToken(t *testing.T) { claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken) require.NoError(t, err) - assert.Equal(t, model.AuthMethodDNS, claims.AuthMethod) + assert.Equal(t, intauth.MethodDNS, claims.AuthMethod) assert.Equal(t, tt.domain, claims.AuthMethodSubject) assert.Len(t, claims.Permissions, 2) // domain and subdomain permissions diff --git a/internal/api/handlers/v0/auth/github_at.go b/internal/api/handlers/v0/auth/github_at.go index d4ef7e0..c1d0e64 100644 --- a/internal/api/handlers/v0/auth/github_at.go +++ b/internal/api/handlers/v0/auth/github_at.go @@ -12,7 +12,6 @@ import ( v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" ) // GitHubTokenExchangeInput represents the input for GitHub token exchange @@ -86,7 +85,7 @@ func (h *GitHubHandler) ExchangeToken(ctx context.Context, githubToken string) ( // Create JWT claims with GitHub user info claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: user.Login, Permissions: permissions, } diff --git a/internal/api/handlers/v0/auth/github_at_test.go b/internal/api/handlers/v0/auth/github_at_test.go index c57beba..c0fa4ac 100644 --- a/internal/api/handlers/v0/auth/github_at_test.go +++ b/internal/api/handlers/v0/auth/github_at_test.go @@ -16,7 +16,6 @@ import ( v0auth "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -78,7 +77,7 @@ func TestGitHubHandler_ExchangeToken(t *testing.T) { jwtManager := auth.NewJWTManager(cfg) claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken) require.NoError(t, err) - assert.Equal(t, model.AuthMethodGitHubAT, claims.AuthMethod) + assert.Equal(t, auth.MethodGitHubAT, claims.AuthMethod) assert.Equal(t, "testuser", claims.AuthMethodSubject) assert.Len(t, claims.Permissions, 1) assert.Equal(t, auth.PermissionActionPublish, claims.Permissions[0].Action) @@ -333,7 +332,7 @@ func TestJWTTokenValidation(t *testing.T) { t.Run("generate and validate token", func(t *testing.T) { // Create test claims claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "testuser", Permissions: []auth.Permission{ { @@ -351,7 +350,7 @@ func TestJWTTokenValidation(t *testing.T) { // Validate token validatedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken) require.NoError(t, err) - assert.Equal(t, model.AuthMethodGitHubAT, validatedClaims.AuthMethod) + assert.Equal(t, auth.MethodGitHubAT, validatedClaims.AuthMethod) assert.Equal(t, "testuser", validatedClaims.AuthMethodSubject) assert.Len(t, validatedClaims.Permissions, 1) }) @@ -360,7 +359,7 @@ func TestJWTTokenValidation(t *testing.T) { // Create claims with past expiration pastTime := time.Now().Add(-1 * time.Hour) claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "testuser", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(pastTime), @@ -381,7 +380,7 @@ func TestJWTTokenValidation(t *testing.T) { t.Run("invalid signature", func(t *testing.T) { // Create test claims claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "testuser", } diff --git a/internal/api/handlers/v0/auth/github_oidc.go b/internal/api/handlers/v0/auth/github_oidc.go index 0cd8f96..9deb89c 100644 --- a/internal/api/handlers/v0/auth/github_oidc.go +++ b/internal/api/handlers/v0/auth/github_oidc.go @@ -15,7 +15,6 @@ import ( v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" ) // GitHubOIDCTokenExchangeInput represents the input for GitHub OIDC token exchange @@ -263,7 +262,7 @@ func (h *GitHubOIDCHandler) ExchangeToken(ctx context.Context, oidcToken string) // Create JWT claims with GitHub OIDC info jwtClaims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubOIDC, + AuthMethod: auth.MethodGitHubOIDC, AuthMethodSubject: claims.Subject, // e.g. "repo:octo-org/octo-repo:environment:prod" Permissions: permissions, } diff --git a/internal/api/handlers/v0/auth/github_oidc_test.go b/internal/api/handlers/v0/auth/github_oidc_test.go index c83470e..a65ab95 100644 --- a/internal/api/handlers/v0/auth/github_oidc_test.go +++ b/internal/api/handlers/v0/auth/github_oidc_test.go @@ -11,7 +11,6 @@ import ( "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth" internalauth "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -106,7 +105,7 @@ func TestGitHubOIDCHandler_ExchangeToken(t *testing.T) { claims, err := jwtManager.ValidateToken(context.Background(), response.RegistryToken) require.NoError(t, err) - assert.Equal(t, model.AuthMethodGitHubOIDC, claims.AuthMethod) + assert.Equal(t, internalauth.MethodGitHubOIDC, claims.AuthMethod) assert.Equal(t, tt.expectedSubject, claims.AuthMethodSubject) assert.Len(t, claims.Permissions, tt.expectedPerms) diff --git a/internal/api/handlers/v0/auth/http.go b/internal/api/handlers/v0/auth/http.go index a261640..bf05dc5 100644 --- a/internal/api/handlers/v0/auth/http.go +++ b/internal/api/handlers/v0/auth/http.go @@ -16,7 +16,6 @@ import ( v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" ) // HTTPTokenExchangeInput represents the input for HTTP-based authentication @@ -182,7 +181,7 @@ func (h *HTTPAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, // Create JWT claims jwtClaims := auth.JWTClaims{ - AuthMethod: model.AuthMethodHTTP, + AuthMethod: auth.MethodHTTP, AuthMethodSubject: domain, Permissions: permissions, } diff --git a/internal/api/handlers/v0/auth/http_test.go b/internal/api/handlers/v0/auth/http_test.go index 6907673..5ea77a6 100644 --- a/internal/api/handlers/v0/auth/http_test.go +++ b/internal/api/handlers/v0/auth/http_test.go @@ -16,7 +16,6 @@ import ( "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth" intauth "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" ) // MockHTTPKeyFetcher for testing @@ -210,7 +209,7 @@ func TestHTTPAuthHandler_ExchangeToken(t *testing.T) { claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken) require.NoError(t, err) - assert.Equal(t, model.AuthMethodHTTP, claims.AuthMethod) + assert.Equal(t, intauth.MethodHTTP, claims.AuthMethod) assert.Equal(t, tt.domain, claims.AuthMethodSubject) assert.Len(t, claims.Permissions, 1) // domain permissions only diff --git a/internal/api/handlers/v0/auth/none.go b/internal/api/handlers/v0/auth/none.go index 41e5288..85f768d 100644 --- a/internal/api/handlers/v0/auth/none.go +++ b/internal/api/handlers/v0/auth/none.go @@ -9,7 +9,6 @@ import ( v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" ) // NoneHandler handles anonymous authentication @@ -66,7 +65,7 @@ func (h *NoneHandler) GetAnonymousToken(ctx context.Context) (*auth.TokenRespons // Create JWT claims for anonymous user claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodNone, + AuthMethod: auth.MethodNone, AuthMethodSubject: "anonymous", Permissions: permissions, } diff --git a/internal/api/handlers/v0/auth/none_test.go b/internal/api/handlers/v0/auth/none_test.go index bc250b3..4d1478f 100644 --- a/internal/api/handlers/v0/auth/none_test.go +++ b/internal/api/handlers/v0/auth/none_test.go @@ -10,7 +10,6 @@ import ( v0auth "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -41,7 +40,7 @@ func TestNoneHandler_GetAnonymousToken(t *testing.T) { require.NoError(t, err) // Check auth method - assert.Equal(t, model.AuthMethodNone, claims.AuthMethod) + assert.Equal(t, auth.MethodNone, claims.AuthMethod) assert.Equal(t, "anonymous", claims.AuthMethodSubject) // Check permissions diff --git a/internal/api/handlers/v0/auth/oidc.go b/internal/api/handlers/v0/auth/oidc.go index 6d27df3..f6b7ef3 100644 --- a/internal/api/handlers/v0/auth/oidc.go +++ b/internal/api/handlers/v0/auth/oidc.go @@ -15,7 +15,6 @@ import ( v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" "golang.org/x/oauth2" ) @@ -301,7 +300,7 @@ func (h *OIDCHandler) ExchangeToken(ctx context.Context, oidcToken string) (*aut // Create JWT claims jwtClaims := auth.JWTClaims{ - AuthMethod: model.AuthMethodOIDC, + AuthMethod: auth.MethodOIDC, AuthMethodSubject: claims.Subject, Permissions: permissions, } diff --git a/internal/api/handlers/v0/edit.go b/internal/api/handlers/v0/edit.go index 8307e21..306338f 100644 --- a/internal/api/handlers/v0/edit.go +++ b/internal/api/handlers/v0/edit.go @@ -11,9 +11,10 @@ import ( "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/database" - "github.com/modelcontextprotocol/registry/internal/model" "github.com/modelcontextprotocol/registry/internal/service" "github.com/modelcontextprotocol/registry/internal/validators" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" ) // EditServerInput represents the input for editing a server @@ -38,7 +39,7 @@ func RegisterEditEndpoints(api huma.API, registry service.RegistryService, cfg * Security: []map[string][]string{ {"bearer": {}}, }, - }, func(ctx context.Context, input *EditServerInput) (*Response[model.ServerResponse], error) { + }, func(ctx context.Context, input *EditServerInput) (*Response[apiv1.ServerRecord], error) { // Extract bearer token const bearerPrefix = "Bearer " authHeader := input.Authorization @@ -54,7 +55,7 @@ func RegisterEditEndpoints(api huma.API, registry service.RegistryService, cfg * } // Validate that only allowed extension fields are present - if err := model.ValidatePublishRequestExtensions(input.RawBody); err != nil { + if err := validators.ValidatePublishRequestExtensions(input.RawBody); err != nil { return nil, huma.Error400BadRequest("Invalid request format", err) } @@ -73,7 +74,7 @@ func RegisterEditEndpoints(api huma.API, registry service.RegistryService, cfg * } // Parse the validated request body - var editRequest model.PublishRequest + var editRequest apiv1.PublishRequest if err := json.Unmarshal(input.RawBody, &editRequest); err != nil { return nil, huma.Error400BadRequest("Invalid JSON format", err) } @@ -90,7 +91,7 @@ func RegisterEditEndpoints(api huma.API, registry service.RegistryService, cfg * } // Prevent undeleting servers - once deleted, they stay deleted - if currentServer.Server.Status == model.ServerStatusDeleted && editRequest.Server.Status != model.ServerStatusDeleted { + if currentServer.Server.Status == model.StatusDeleted && editRequest.Server.Status != model.StatusDeleted { return nil, huma.Error400BadRequest("Cannot change status of deleted server. Deleted servers cannot be undeleted.") } @@ -103,7 +104,7 @@ func RegisterEditEndpoints(api huma.API, registry service.RegistryService, cfg * return nil, huma.Error400BadRequest("Failed to edit server", err) } - return &Response[model.ServerResponse]{ + return &Response[apiv1.ServerRecord]{ Body: *updatedServer, }, nil }) diff --git a/internal/api/handlers/v0/edit_test.go b/internal/api/handlers/v0/edit_test.go index 820deae..403946a 100644 --- a/internal/api/handlers/v0/edit_test.go +++ b/internal/api/handlers/v0/edit_test.go @@ -17,7 +17,8 @@ import ( "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/database" - "github.com/modelcontextprotocol/registry/internal/model" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" ) func TestEditServerEndpoint(t *testing.T) { @@ -36,7 +37,7 @@ func TestEditServerEndpoint(t *testing.T) { authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "domdomegg", Permissions: []auth.Permission{ {Action: auth.PermissionActionEdit, ResourcePattern: "io.github.domdomegg/*"}, @@ -44,11 +45,11 @@ func TestEditServerEndpoint(t *testing.T) { }) return "Bearer " + token }(), - requestBody: model.PublishRequest{ - Server: model.ServerDetail{ + requestBody: apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "io.github.domdomegg/test-server", Description: "Updated test server", - Status: model.ServerStatusDeprecated, + Status: model.StatusDeprecated, Repository: model.Repository{ URL: "https://github.com/domdomegg/test-server", Source: "github", @@ -61,23 +62,23 @@ func TestEditServerEndpoint(t *testing.T) { }, setupMocks: func(registry *MockRegistryService) { // Current server (not deleted) - currentServer := &model.ServerResponse{ - Server: model.ServerDetail{ + currentServer := &apiv1.ServerRecord{ + Server: model.ServerJSON{ Name: "io.github.domdomegg/test-server", Description: "Original server", - Status: model.ServerStatusActive, + Status: model.StatusActive, }, } registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil) - - expectedResponse := &model.ServerResponse{ - Server: model.ServerDetail{ + + expectedResponse := &apiv1.ServerRecord{ + Server: model.ServerJSON{ Name: "io.github.domdomegg/test-server", Description: "Updated test server", - Status: model.ServerStatusDeprecated, + Status: model.StatusDeprecated, }, } - registry.On("EditServer", "550e8400-e29b-41d4-a716-446655440001", mock.AnythingOfType("model.PublishRequest")).Return(expectedResponse, nil) + registry.On("EditServer", "550e8400-e29b-41d4-a716-446655440001", mock.AnythingOfType("v1.PublishRequest")).Return(expectedResponse, nil) }, expectedStatus: http.StatusOK, }, @@ -85,7 +86,7 @@ func TestEditServerEndpoint(t *testing.T) { name: "missing authorization header", serverID: "550e8400-e29b-41d4-a716-446655440001", authHeader: "", - requestBody: model.PublishRequest{}, + requestBody: apiv1.PublishRequest{}, setupMocks: func(_ *MockRegistryService) {}, expectedStatus: 422, expectedError: "required header parameter is missing", @@ -94,7 +95,7 @@ func TestEditServerEndpoint(t *testing.T) { name: "invalid authorization header format", serverID: "550e8400-e29b-41d4-a716-446655440001", authHeader: "InvalidFormat token123", - requestBody: model.PublishRequest{}, + requestBody: apiv1.PublishRequest{}, setupMocks: func(_ *MockRegistryService) {}, expectedStatus: http.StatusUnauthorized, expectedError: "Unauthorized", @@ -103,7 +104,7 @@ func TestEditServerEndpoint(t *testing.T) { name: "invalid token", serverID: "550e8400-e29b-41d4-a716-446655440001", authHeader: "Bearer invalid-token", - requestBody: model.PublishRequest{}, + requestBody: apiv1.PublishRequest{}, setupMocks: func(_ *MockRegistryService) {}, expectedStatus: http.StatusUnauthorized, expectedError: "Unauthorized", @@ -114,7 +115,7 @@ func TestEditServerEndpoint(t *testing.T) { authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "domdomegg", Permissions: []auth.Permission{ {Action: auth.PermissionActionPublish, ResourcePattern: "io.github.domdomegg/*"}, @@ -122,19 +123,19 @@ func TestEditServerEndpoint(t *testing.T) { }) return "Bearer " + token }(), - requestBody: model.PublishRequest{ - Server: model.ServerDetail{ + requestBody: apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "io.github.domdomegg/test-server", Description: "Updated test server", }, }, setupMocks: func(registry *MockRegistryService) { // Need to mock GetByID since we check permissions against existing server name - currentServer := &model.ServerResponse{ - Server: model.ServerDetail{ + currentServer := &apiv1.ServerRecord{ + Server: model.ServerJSON{ Name: "io.github.domdomegg/test-server", Description: "Original server", - Status: model.ServerStatusActive, + Status: model.StatusActive, }, } registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil) @@ -148,7 +149,7 @@ func TestEditServerEndpoint(t *testing.T) { authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "domdomegg", Permissions: []auth.Permission{ {Action: auth.PermissionActionEdit, ResourcePattern: "io.github.domdomegg/*"}, @@ -156,8 +157,8 @@ func TestEditServerEndpoint(t *testing.T) { }) return "Bearer " + token }(), - requestBody: model.PublishRequest{ - Server: model.ServerDetail{ + requestBody: apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "io.github.other/test-server", Description: "Updated test server", }, @@ -165,11 +166,11 @@ func TestEditServerEndpoint(t *testing.T) { 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 := &model.ServerResponse{ - Server: model.ServerDetail{ + currentServer := &apiv1.ServerRecord{ + Server: model.ServerJSON{ Name: "io.github.other/test-server", Description: "Original server", - Status: model.ServerStatusActive, + Status: model.StatusActive, }, } registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil) @@ -183,7 +184,7 @@ func TestEditServerEndpoint(t *testing.T) { authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "domdomegg", Permissions: []auth.Permission{ {Action: auth.PermissionActionEdit, ResourcePattern: "io.github.domdomegg/*"}, @@ -191,8 +192,8 @@ func TestEditServerEndpoint(t *testing.T) { }) return "Bearer " + token }(), - requestBody: model.PublishRequest{ - Server: model.ServerDetail{ + requestBody: apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "io.github.domdomegg/test-server", Description: "Updated test server", }, @@ -209,7 +210,7 @@ func TestEditServerEndpoint(t *testing.T) { authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "domdomegg", Permissions: []auth.Permission{ {Action: auth.PermissionActionEdit, ResourcePattern: "*"}, @@ -217,23 +218,23 @@ func TestEditServerEndpoint(t *testing.T) { }) return "Bearer " + token }(), - requestBody: model.PublishRequest{ - Server: model.ServerDetail{ + requestBody: apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "io.github.domdomegg/test-server", Description: "Updated test server", }, }, setupMocks: func(registry *MockRegistryService) { // Current server (not deleted) - currentServer := &model.ServerResponse{ - Server: model.ServerDetail{ + currentServer := &apiv1.ServerRecord{ + Server: model.ServerJSON{ Name: "io.github.domdomegg/test-server", Description: "Original server", - Status: model.ServerStatusActive, + Status: model.StatusActive, }, } registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil) - registry.On("EditServer", "550e8400-e29b-41d4-a716-446655440001", mock.AnythingOfType("model.PublishRequest")).Return(nil, errors.New("database error")) + registry.On("EditServer", "550e8400-e29b-41d4-a716-446655440001", mock.AnythingOfType("v1.PublishRequest")).Return(nil, errors.New("database error")) }, expectedStatus: http.StatusBadRequest, expectedError: "Bad Request", @@ -244,7 +245,7 @@ func TestEditServerEndpoint(t *testing.T) { authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "domdomegg", Permissions: []auth.Permission{ {Action: auth.PermissionActionEdit, ResourcePattern: "*"}, @@ -263,7 +264,7 @@ func TestEditServerEndpoint(t *testing.T) { authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "domdomegg", Permissions: []auth.Permission{ {Action: auth.PermissionActionEdit, ResourcePattern: "*"}, @@ -271,20 +272,20 @@ func TestEditServerEndpoint(t *testing.T) { }) return "Bearer " + token }(), - requestBody: model.PublishRequest{ - Server: model.ServerDetail{ + requestBody: apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "io.github.domdomegg/test-server", Description: "Trying to undelete server", - Status: model.ServerStatusActive, + Status: model.StatusActive, }, }, setupMocks: func(registry *MockRegistryService) { // Current server is deleted - currentServer := &model.ServerResponse{ - Server: model.ServerDetail{ + currentServer := &apiv1.ServerRecord{ + Server: model.ServerJSON{ Name: "io.github.domdomegg/test-server", Description: "Original server", - Status: model.ServerStatusDeleted, + Status: model.StatusDeleted, }, } registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil) @@ -345,4 +346,4 @@ func TestEditServerEndpoint(t *testing.T) { mockRegistry.AssertExpectations(t) }) } -} \ No newline at end of file +} diff --git a/internal/api/handlers/v0/publish.go b/internal/api/handlers/v0/publish.go index 8057439..d6dc785 100644 --- a/internal/api/handlers/v0/publish.go +++ b/internal/api/handlers/v0/publish.go @@ -9,9 +9,9 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" "github.com/modelcontextprotocol/registry/internal/service" "github.com/modelcontextprotocol/registry/internal/validators" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" ) // PublishServerInput represents the input for publishing a server @@ -35,7 +35,7 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg Security: []map[string][]string{ {"bearer": {}}, }, - }, func(ctx context.Context, input *PublishServerInput) (*Response[model.ServerResponse], error) { + }, func(ctx context.Context, input *PublishServerInput) (*Response[apiv1.ServerRecord], error) { // Extract bearer token const bearerPrefix = "Bearer " authHeader := input.Authorization @@ -51,12 +51,12 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg } // Validate that only allowed extension fields are present - if err := model.ValidatePublishRequestExtensions(input.RawBody); err != nil { + if err := validators.ValidatePublishRequestExtensions(input.RawBody); err != nil { return nil, huma.Error400BadRequest("Invalid request format", err) } // Parse the validated request body - var publishRequest model.PublishRequest + var publishRequest apiv1.PublishRequest if err := json.Unmarshal(input.RawBody, &publishRequest); err != nil { return nil, huma.Error400BadRequest("Invalid JSON format", err) } @@ -82,7 +82,7 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg } // Return the published server in extension wrapper format - return &Response[model.ServerResponse]{ + return &Response[apiv1.ServerRecord]{ Body: *publishedServer, }, nil }) diff --git a/internal/api/handlers/v0/publish_integration_test.go b/internal/api/handlers/v0/publish_integration_test.go index f7f3394..7126bab 100644 --- a/internal/api/handlers/v0/publish_integration_test.go +++ b/internal/api/handlers/v0/publish_integration_test.go @@ -16,8 +16,9 @@ import ( v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" "github.com/modelcontextprotocol/registry/internal/service" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -53,8 +54,8 @@ func TestPublishIntegration(t *testing.T) { v0.RegisterPublishEndpoint(api, registryService, testConfig) t.Run("successful publish with GitHub auth", func(t *testing.T) { - publishReq := model.PublishRequest{ - Server: model.ServerDetail{ + publishReq := apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "io.github.testuser/test-mcp-server", Description: "A test MCP server for integration testing", Repository: model.Repository{ @@ -70,7 +71,7 @@ func TestPublishIntegration(t *testing.T) { // Generate valid JWT token claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "testuser", Permissions: []auth.Permission{ {Action: auth.PermissionActionPublish, ResourcePattern: "io.github.testuser/*"}, @@ -91,7 +92,7 @@ func TestPublishIntegration(t *testing.T) { assert.Equal(t, http.StatusOK, rr.Code) - var response model.ServerResponse + var response apiv1.ServerRecord err = json.Unmarshal(rr.Body.Bytes(), &response) require.NoError(t, err) @@ -100,8 +101,8 @@ func TestPublishIntegration(t *testing.T) { }) t.Run("successful publish with none auth (no prefix)", func(t *testing.T) { - publishReq := model.PublishRequest{ - Server: model.ServerDetail{ + publishReq := apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "com.example/test-mcp-server-no-auth", Description: "A test MCP server without authentication", Repository: model.Repository{ @@ -117,7 +118,7 @@ func TestPublishIntegration(t *testing.T) { // Generate valid JWT token with wildcard permission claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodNone, + AuthMethod: auth.MethodNone, Permissions: []auth.Permission{ {Action: auth.PermissionActionPublish, ResourcePattern: "*"}, }, @@ -137,7 +138,7 @@ func TestPublishIntegration(t *testing.T) { assert.Equal(t, http.StatusOK, rr.Code) - var response model.ServerResponse + var response apiv1.ServerRecord err = json.Unmarshal(rr.Body.Bytes(), &response) require.NoError(t, err) @@ -145,8 +146,8 @@ func TestPublishIntegration(t *testing.T) { }) t.Run("publish fails with missing authorization header", func(t *testing.T) { - publishReq := model.PublishRequest{ - Server: model.ServerDetail{ + publishReq := apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "test-server", }, } @@ -166,8 +167,8 @@ func TestPublishIntegration(t *testing.T) { }) t.Run("publish fails with invalid token", func(t *testing.T) { - publishReq := model.PublishRequest{ - Server: model.ServerDetail{ + publishReq := apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "test-server", }, } @@ -187,8 +188,8 @@ func TestPublishIntegration(t *testing.T) { }) t.Run("publish fails when permission denied", func(t *testing.T) { - publishReq := model.PublishRequest{ - Server: model.ServerDetail{ + publishReq := apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "io.github.other/test-server", Description: "A test server", VersionDetail: model.VersionDetail{ @@ -204,7 +205,7 @@ func TestPublishIntegration(t *testing.T) { // Generate valid JWT token but with different permissions claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, Permissions: []auth.Permission{ {Action: auth.PermissionActionPublish, ResourcePattern: "io.github.myuser/*"}, }, diff --git a/internal/api/handlers/v0/publish_test.go b/internal/api/handlers/v0/publish_test.go index 48b2b69..ffb4954 100644 --- a/internal/api/handlers/v0/publish_test.go +++ b/internal/api/handlers/v0/publish_test.go @@ -17,7 +17,8 @@ import ( v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -28,33 +29,33 @@ type MockRegistryService struct { mock.Mock } -func (m *MockRegistryService) List(cursor string, limit int) ([]model.ServerResponse, string, error) { +func (m *MockRegistryService) List(cursor string, limit int) ([]apiv1.ServerRecord, string, error) { args := m.Called(cursor, limit) - return args.Get(0).([]model.ServerResponse), args.String(1), args.Error(2) + return args.Get(0).([]apiv1.ServerRecord), args.String(1), args.Error(2) } -func (m *MockRegistryService) GetByID(id string) (*model.ServerResponse, error) { +func (m *MockRegistryService) GetByID(id string) (*apiv1.ServerRecord, error) { args := m.Called(id) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*model.ServerResponse), args.Error(1) + return args.Get(0).(*apiv1.ServerRecord), args.Error(1) } -func (m *MockRegistryService) Publish(request model.PublishRequest) (*model.ServerResponse, error) { +func (m *MockRegistryService) Publish(request apiv1.PublishRequest) (*apiv1.ServerRecord, error) { args := m.Called(request) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*model.ServerResponse), args.Error(1) + return args.Get(0).(*apiv1.ServerRecord), args.Error(1) } -func (m *MockRegistryService) EditServer(id string, request model.PublishRequest) (*model.ServerResponse, error) { +func (m *MockRegistryService) EditServer(id string, request apiv1.PublishRequest) (*apiv1.ServerRecord, error) { args := m.Called(id, request) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*model.ServerResponse), args.Error(1) + return args.Get(0).(*apiv1.ServerRecord), args.Error(1) } // Helper function to generate a valid JWT token for testing @@ -87,8 +88,8 @@ func TestPublishEndpoint(t *testing.T) { }{ { name: "successful publish with GitHub auth", - requestBody: model.PublishRequest{ - Server: model.ServerDetail{ + requestBody: apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "io.github.example/test-server", Description: "A test server", Repository: model.Repository{ @@ -102,21 +103,21 @@ func TestPublishEndpoint(t *testing.T) { }, }, tokenClaims: &auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "example", Permissions: []auth.Permission{ {Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"}, }, }, setupMocks: func(registry *MockRegistryService) { - registry.On("Publish", mock.AnythingOfType("model.PublishRequest")).Return(&model.ServerResponse{}, nil) + registry.On("Publish", mock.AnythingOfType("v1.PublishRequest")).Return(&apiv1.ServerRecord{}, nil) }, expectedStatus: http.StatusOK, }, { name: "successful publish with no auth (AuthMethodNone)", - requestBody: model.PublishRequest{ - Server: model.ServerDetail{ + requestBody: apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "example/test-server", Description: "A test server without auth", Repository: model.Repository{ @@ -130,19 +131,19 @@ func TestPublishEndpoint(t *testing.T) { }, }, tokenClaims: &auth.JWTClaims{ - AuthMethod: model.AuthMethodNone, + AuthMethod: auth.MethodNone, Permissions: []auth.Permission{ {Action: auth.PermissionActionPublish, ResourcePattern: "example/*"}, }, }, setupMocks: func(registry *MockRegistryService) { - registry.On("Publish", mock.AnythingOfType("model.PublishRequest")).Return(&model.ServerResponse{}, nil) + registry.On("Publish", mock.AnythingOfType("v1.PublishRequest")).Return(&apiv1.ServerRecord{}, nil) }, expectedStatus: http.StatusOK, }, { name: "missing authorization header", - requestBody: model.PublishRequest{}, + requestBody: apiv1.PublishRequest{}, authHeader: "", // Empty auth header setupMocks: func(_ *MockRegistryService) {}, expectedStatus: http.StatusUnprocessableEntity, @@ -150,7 +151,7 @@ func TestPublishEndpoint(t *testing.T) { }, { name: "invalid authorization header format", - requestBody: model.PublishRequest{}, + requestBody: apiv1.PublishRequest{}, authHeader: "InvalidFormat", setupMocks: func(_ *MockRegistryService) {}, expectedStatus: http.StatusUnauthorized, @@ -158,8 +159,8 @@ func TestPublishEndpoint(t *testing.T) { }, { name: "invalid token", - requestBody: model.PublishRequest{ - Server: model.ServerDetail{ + requestBody: apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "test-server", Description: "A test server", VersionDetail: model.VersionDetail{ @@ -174,8 +175,8 @@ func TestPublishEndpoint(t *testing.T) { }, { name: "permission denied", - requestBody: model.PublishRequest{ - Server: model.ServerDetail{ + requestBody: apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "io.github.other/test-server", Description: "A test server", VersionDetail: model.VersionDetail{ @@ -189,7 +190,7 @@ func TestPublishEndpoint(t *testing.T) { }, }, tokenClaims: &auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, Permissions: []auth.Permission{ {Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"}, }, @@ -200,8 +201,8 @@ func TestPublishEndpoint(t *testing.T) { }, { name: "registry service error", - requestBody: model.PublishRequest{ - Server: model.ServerDetail{ + requestBody: apiv1.PublishRequest{ + Server: model.ServerJSON{ Name: "example/test-server", Description: "A test server", VersionDetail: model.VersionDetail{ @@ -215,13 +216,13 @@ func TestPublishEndpoint(t *testing.T) { }, }, tokenClaims: &auth.JWTClaims{ - AuthMethod: model.AuthMethodNone, + AuthMethod: auth.MethodNone, Permissions: []auth.Permission{ {Action: auth.PermissionActionPublish, ResourcePattern: "*"}, }, }, setupMocks: func(registry *MockRegistryService) { - registry.On("Publish", mock.AnythingOfType("model.PublishRequest")).Return(nil, errors.New("cannot publish duplicate version")) + registry.On("Publish", mock.AnythingOfType("v1.PublishRequest")).Return(nil, errors.New("cannot publish duplicate version")) }, expectedStatus: http.StatusBadRequest, expectedError: "Failed to publish server", diff --git a/internal/api/handlers/v0/servers.go b/internal/api/handlers/v0/servers.go index cfc731d..d15b31d 100644 --- a/internal/api/handlers/v0/servers.go +++ b/internal/api/handlers/v0/servers.go @@ -6,8 +6,8 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/google/uuid" - "github.com/modelcontextprotocol/registry/internal/model" "github.com/modelcontextprotocol/registry/internal/service" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" ) // Metadata contains pagination metadata @@ -25,8 +25,8 @@ type ListServersInput struct { // ListServersBody represents the paginated server list response body type ListServersBody struct { - Servers []model.ServerResponse `json:"servers" doc:"List of MCP servers with extensions"` - Metadata *Metadata `json:"metadata,omitempty" doc:"Pagination metadata"` + Servers []apiv1.ServerRecord `json:"servers" doc:"List of MCP servers with extensions"` + Metadata *Metadata `json:"metadata,omitempty" doc:"Pagination metadata"` } // ServerDetailInput represents the input for getting server details @@ -85,7 +85,7 @@ func RegisterServersEndpoints(api huma.API, registry service.RegistryService) { Summary: "Get MCP server details", Description: "Get detailed information about a specific MCP server", Tags: []string{"servers"}, - }, func(_ context.Context, input *ServerDetailInput) (*Response[model.ServerResponse], error) { + }, func(_ context.Context, input *ServerDetailInput) (*Response[apiv1.ServerRecord], error) { // Get the server details from the registry service serverDetail, err := registry.GetByID(input.ID) if err != nil { @@ -95,7 +95,7 @@ func RegisterServersEndpoints(api huma.API, registry service.RegistryService) { return nil, huma.Error500InternalServerError("Failed to get server details", err) } - return &Response[model.ServerResponse]{ + return &Response[apiv1.ServerRecord]{ Body: *serverDetail, }, nil }) diff --git a/internal/api/handlers/v0/servers_test.go b/internal/api/handlers/v0/servers_test.go index 6324917..37c2c08 100644 --- a/internal/api/handlers/v0/servers_test.go +++ b/internal/api/handlers/v0/servers_test.go @@ -12,7 +12,8 @@ import ( "github.com/danielgtaylor/huma/v2/adapters/humago" "github.com/google/uuid" v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" - "github.com/modelcontextprotocol/registry/internal/model" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -23,16 +24,16 @@ func TestServersListEndpoint(t *testing.T) { queryParams string setupMocks func(*MockRegistryService) expectedStatus int - expectedServers []model.ServerResponse + expectedServers []apiv1.ServerRecord expectedMeta *v0.Metadata expectedError string }{ { name: "successful list with default parameters", setupMocks: func(registry *MockRegistryService) { - servers := []model.ServerResponse{ + servers := []apiv1.ServerRecord{ { - Server: model.ServerDetail{ + Server: model.ServerJSON{ Name: "test-server-1", Description: "First test server", Repository: model.Repository{ @@ -44,12 +45,12 @@ func TestServersListEndpoint(t *testing.T) { Version: "1.0.0", }, }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": "550e8400-e29b-41d4-a716-446655440001", + XIOModelContextProtocolRegistry: apiv1.RegistryExtensions{ + ID: "550e8400-e29b-41d4-a716-446655440001", }, }, { - Server: model.ServerDetail{ + Server: model.ServerJSON{ Name: "test-server-2", Description: "Second test server", Repository: model.Repository{ @@ -61,17 +62,17 @@ func TestServersListEndpoint(t *testing.T) { Version: "2.0.0", }, }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": "550e8400-e29b-41d4-a716-446655440002", + XIOModelContextProtocolRegistry: apiv1.RegistryExtensions{ + ID: "550e8400-e29b-41d4-a716-446655440002", }, }, } registry.Mock.On("List", "", 30).Return(servers, "", nil) }, expectedStatus: http.StatusOK, - expectedServers: []model.ServerResponse{ + expectedServers: []apiv1.ServerRecord{ { - Server: model.ServerDetail{ + Server: model.ServerJSON{ Name: "test-server-1", Description: "First test server", Repository: model.Repository{ @@ -83,12 +84,12 @@ func TestServersListEndpoint(t *testing.T) { Version: "1.0.0", }, }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": "550e8400-e29b-41d4-a716-446655440001", + XIOModelContextProtocolRegistry: apiv1.RegistryExtensions{ + ID: "550e8400-e29b-41d4-a716-446655440001", }, }, { - Server: model.ServerDetail{ + Server: model.ServerJSON{ Name: "test-server-2", Description: "Second test server", Repository: model.Repository{ @@ -100,8 +101,8 @@ func TestServersListEndpoint(t *testing.T) { Version: "2.0.0", }, }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": "550e8400-e29b-41d4-a716-446655440002", + XIOModelContextProtocolRegistry: apiv1.RegistryExtensions{ + ID: "550e8400-e29b-41d4-a716-446655440002", }, }, }, @@ -110,9 +111,9 @@ func TestServersListEndpoint(t *testing.T) { name: "successful list with cursor and limit", queryParams: "?cursor=550e8400-e29b-41d4-a716-446655440000&limit=10", setupMocks: func(registry *MockRegistryService) { - servers := []model.ServerResponse{ + servers := []apiv1.ServerRecord{ { - Server: model.ServerDetail{ + Server: model.ServerJSON{ Name: "test-server-3", Description: "Third test server", Repository: model.Repository{ @@ -124,8 +125,8 @@ func TestServersListEndpoint(t *testing.T) { Version: "1.5.0", }, }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": "550e8400-e29b-41d4-a716-446655440003", + XIOModelContextProtocolRegistry: apiv1.RegistryExtensions{ + ID: "550e8400-e29b-41d4-a716-446655440003", }, }, } @@ -133,9 +134,9 @@ func TestServersListEndpoint(t *testing.T) { registry.Mock.On("List", mock.AnythingOfType("string"), 10).Return(servers, nextCursor, nil) }, expectedStatus: http.StatusOK, - expectedServers: []model.ServerResponse{ + expectedServers: []apiv1.ServerRecord{ { - Server: model.ServerDetail{ + Server: model.ServerJSON{ Name: "test-server-3", Description: "Third test server", Repository: model.Repository{ @@ -147,8 +148,8 @@ func TestServersListEndpoint(t *testing.T) { Version: "1.5.0", }, }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": "550e8400-e29b-41d4-a716-446655440003", + XIOModelContextProtocolRegistry: apiv1.RegistryExtensions{ + ID: "550e8400-e29b-41d4-a716-446655440003", }, }, }, @@ -195,7 +196,7 @@ func TestServersListEndpoint(t *testing.T) { { name: "registry service error", setupMocks: func(registry *MockRegistryService) { - registry.Mock.On("List", "", 30).Return([]model.ServerResponse{}, "", errors.New("database connection error")) + registry.Mock.On("List", "", 30).Return([]apiv1.ServerRecord{}, "", errors.New("database connection error")) }, expectedStatus: http.StatusInternalServerError, expectedError: "Failed to get registry list", @@ -232,8 +233,8 @@ func TestServersListEndpoint(t *testing.T) { // Parse response body var resp struct { - Servers []model.ServerResponse `json:"servers"` - Metadata *v0.Metadata `json:"metadata,omitempty"` + Servers []apiv1.ServerRecord `json:"servers"` + Metadata *v0.Metadata `json:"metadata,omitempty"` } err := json.NewDecoder(w.Body).Decode(&resp) assert.NoError(t, err) @@ -267,15 +268,15 @@ func TestServersDetailEndpoint(t *testing.T) { serverID string setupMocks func(*MockRegistryService, string) expectedStatus int - expectedServer *model.ServerResponse + expectedServer *apiv1.ServerRecord expectedError string }{ { name: "successful get server detail", serverID: uuid.New().String(), setupMocks: func(registry *MockRegistryService, serverID string) { - serverDetail := &model.ServerResponse{ - Server: model.ServerDetail{ + serverDetail := &apiv1.ServerRecord{ + Server: model.ServerJSON{ Name: "test-server-detail", Description: "Test server detail", Repository: model.Repository{ @@ -287,8 +288,8 @@ func TestServersDetailEndpoint(t *testing.T) { Version: "2.0.0", }, }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": serverID, + XIOModelContextProtocolRegistry: apiv1.RegistryExtensions{ + ID: serverID, }, } registry.Mock.On("GetByID", serverID).Return(serverDetail, nil) @@ -351,7 +352,7 @@ func TestServersDetailEndpoint(t *testing.T) { assert.Equal(t, "application/json", w.Header().Get("Content-Type")) // Parse response body - var serverDetailResp model.ServerResponse + var serverDetailResp apiv1.ServerRecord err := json.NewDecoder(w.Body).Decode(&serverDetailResp) assert.NoError(t, err) @@ -375,9 +376,9 @@ func TestServersEndpointsIntegration(t *testing.T) { // Test data serverID := uuid.New().String() - servers := []model.ServerResponse{ + servers := []apiv1.ServerRecord{ { - Server: model.ServerDetail{ + Server: model.ServerJSON{ Name: "integration-test-server", Description: "Integration test server", Repository: model.Repository{ @@ -389,14 +390,14 @@ func TestServersEndpointsIntegration(t *testing.T) { Version: "1.0.0", }, }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": serverID, + XIOModelContextProtocolRegistry: apiv1.RegistryExtensions{ + ID: serverID, }, }, } - serverDetail := &model.ServerResponse{ - Server: servers[0].Server, + serverDetail := &apiv1.ServerRecord{ + Server: servers[0].Server, XIOModelContextProtocolRegistry: servers[0].XIOModelContextProtocolRegistry, } @@ -438,7 +439,7 @@ func TestServersEndpointsIntegration(t *testing.T) { // Parse response body var listResp struct { - Servers []model.ServerResponse `json:"servers"` + Servers []apiv1.ServerRecord `json:"servers"` } err = json.NewDecoder(resp.Body).Decode(&listResp) assert.NoError(t, err) @@ -469,7 +470,7 @@ func TestServersEndpointsIntegration(t *testing.T) { assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) // Parse response body - var serverDetailResp model.ServerResponse + var serverDetailResp apiv1.ServerRecord err = json.NewDecoder(resp.Body).Decode(&serverDetailResp) assert.NoError(t, err) @@ -479,4 +480,4 @@ func TestServersEndpointsIntegration(t *testing.T) { // Verify mock expectations mockRegistry.AssertExpectations(t) -} \ No newline at end of file +} diff --git a/internal/api/handlers/v0/telemetry_test.go b/internal/api/handlers/v0/telemetry_test.go index dd9062b..1ef8762 100644 --- a/internal/api/handlers/v0/telemetry_test.go +++ b/internal/api/handlers/v0/telemetry_test.go @@ -14,13 +14,14 @@ import ( v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/api/router" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" "github.com/modelcontextprotocol/registry/internal/telemetry" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" ) func mockServerEndpoint(registry *MockRegistryService, serverID string) { - serverDetail := &model.ServerResponse{ - Server: model.ServerDetail{ + serverDetail := &apiv1.ServerRecord{ + Server: model.ServerJSON{ Name: "test-server-detail", Description: "Test server detail", Repository: model.Repository{ @@ -32,8 +33,8 @@ func mockServerEndpoint(registry *MockRegistryService, serverID string) { Version: "2.0.0", }, }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": serverID, + XIOModelContextProtocolRegistry: apiv1.RegistryExtensions{ + ID: serverID, }, } registry.Mock.On("GetByID", serverID).Return(serverDetail, nil) diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 19c97e8..4558666 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -10,7 +10,6 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" ) // PermissionAction represents the type of action that can be performed @@ -31,9 +30,9 @@ type Permission struct { type JWTClaims struct { jwt.RegisteredClaims // Authentication method used to obtain this token - AuthMethod model.AuthMethod `json:"auth_method"` - AuthMethodSubject string `json:"auth_method_sub"` - Permissions []Permission `json:"permissions"` + AuthMethod Method `json:"auth_method"` + AuthMethodSubject string `json:"auth_method_sub"` + Permissions []Permission `json:"permissions"` } type TokenResponse struct { diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go index 42615da..6e9d718 100644 --- a/internal/auth/jwt_test.go +++ b/internal/auth/jwt_test.go @@ -11,7 +11,6 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/modelcontextprotocol/registry/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,7 +30,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) { t.Run("generate and verify valid token", func(t *testing.T) { claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "testuser", Permissions: []auth.Permission{ { @@ -50,7 +49,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) { // Verify token verifiedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken) require.NoError(t, err) - assert.Equal(t, model.AuthMethodGitHubAT, verifiedClaims.AuthMethod) + assert.Equal(t, auth.MethodGitHubAT, verifiedClaims.AuthMethod) assert.Equal(t, "testuser", verifiedClaims.AuthMethodSubject) assert.Equal(t, "mcp-registry", verifiedClaims.Issuer) assert.Len(t, verifiedClaims.Permissions, 1) @@ -70,7 +69,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) { NotBefore: notBefore, Issuer: "custom-issuer", }, - AuthMethod: model.AuthMethodNone, + AuthMethod: auth.MethodNone, AuthMethodSubject: "anonymous", Permissions: []auth.Permission{ { @@ -87,7 +86,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) { // Verify token verifiedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken) require.NoError(t, err) - assert.Equal(t, model.AuthMethodNone, verifiedClaims.AuthMethod) + assert.Equal(t, auth.MethodNone, verifiedClaims.AuthMethod) assert.Equal(t, "anonymous", verifiedClaims.AuthMethodSubject) assert.Equal(t, "custom-issuer", verifiedClaims.Issuer) assert.Equal(t, issuedAt.Unix(), verifiedClaims.IssuedAt.Unix()) @@ -100,7 +99,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) { RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), // Already expired }, - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "testuser", } @@ -126,7 +125,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) { differentJWTManager := auth.NewJWTManager(differentCfg) claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "testuser", } @@ -149,7 +148,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) { t.Run("multiple permissions", func(t *testing.T) { claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "admin", Permissions: []auth.Permission{ { @@ -305,7 +304,7 @@ func TestJWTManager_BlockedNamespaces(t *testing.T) { jwtManager := auth.NewJWTManager(cfg) claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "spammer", Permissions: []auth.Permission{ { @@ -330,7 +329,7 @@ func TestJWTManager_BlockedNamespaces(t *testing.T) { jwtManager := auth.NewJWTManager(cfg) claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "gooduser", Permissions: []auth.Permission{ { @@ -354,7 +353,7 @@ func TestJWTManager_BlockedNamespaces(t *testing.T) { jwtManager := auth.NewJWTManager(cfg) claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodGitHubAT, + AuthMethod: auth.MethodGitHubAT, AuthMethodSubject: "user", Permissions: []auth.Permission{ { @@ -383,7 +382,7 @@ func TestJWTManager_BlockedNamespaces(t *testing.T) { jwtManager := auth.NewJWTManager(cfg) claims := auth.JWTClaims{ - AuthMethod: model.AuthMethodNone, + AuthMethod: auth.MethodNone, AuthMethodSubject: "admin", Permissions: []auth.Permission{ { diff --git a/internal/auth/types.go b/internal/auth/types.go new file mode 100644 index 0000000..83cbd2f --- /dev/null +++ b/internal/auth/types.go @@ -0,0 +1,19 @@ +package auth + +// Method represents the authentication method used +type Method string + +const ( + // GitHub OAuth authentication (access token) + MethodGitHubAT Method = "github-at" + // GitHub Actions OIDC authentication + MethodGitHubOIDC Method = "github-oidc" + // Generic OIDC authentication + MethodOIDC Method = "oidc" + // DNS-based public/private key authentication + MethodDNS Method = "dns" + // HTTP-based public/private key authentication + MethodHTTP Method = "http" + // No authentication - should only be used for local development and testing + MethodNone Method = "none" +) \ No newline at end of file diff --git a/internal/database/database.go b/internal/database/database.go index 904720b..f7a575c 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -4,7 +4,8 @@ import ( "context" "errors" - "github.com/modelcontextprotocol/registry/internal/model" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" ) // Common database errors @@ -20,16 +21,16 @@ var ( // Database defines the interface for database operations with extension wrapper architecture type Database interface { // List retrieves all ServerRecord entries with optional filtering - List(ctx context.Context, filter map[string]any, cursor string, limit int) ([]*model.ServerRecord, string, error) + List(ctx context.Context, filter map[string]any, cursor string, limit int) ([]*apiv1.ServerRecord, string, error) // GetByID retrieves a single ServerRecord by its ID - GetByID(ctx context.Context, id string) (*model.ServerRecord, error) + GetByID(ctx context.Context, id string) (*apiv1.ServerRecord, error) // Publish adds a new server to the database with separated server.json and extensions // The registryMetadata contains metadata determined by the service layer (e.g., is_latest, timestamps) - Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, registryMetadata model.RegistryMetadata) (*model.ServerRecord, error) + Publish(ctx context.Context, serverDetail model.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv1.RegistryExtensions) (*apiv1.ServerRecord, error) // UpdateLatestFlag updates the is_latest flag for a specific server record 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 model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error) + UpdateServer(ctx context.Context, id string, serverDetail model.ServerJSON, publisherExtensions map[string]interface{}) (*apiv1.ServerRecord, error) // ImportSeed imports initial data from a seed file ImportSeed(ctx context.Context, seedFilePath string) error // Close closes the database connection diff --git a/internal/database/import.go b/internal/database/import.go index 3ef33e3..35cdf29 100644 --- a/internal/database/import.go +++ b/internal/database/import.go @@ -8,17 +8,16 @@ import ( "net/http" "os" "strings" - "time" - "github.com/modelcontextprotocol/registry/internal/model" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" ) // ReadSeedFile reads 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 +// 2. Direct HTTP URLs to seed.json files - expects extension wrapper format // 3. Registry root URLs (automatically appends /v0/servers and paginates) // Only the extension wrapper format is supported (array of ServerResponse objects) -func ReadSeedFile(ctx context.Context, path string) ([]*model.ServerRecord, error) { +func ReadSeedFile(ctx context.Context, path string) ([]*apiv1.ServerRecord, error) { var data []byte var err error @@ -40,17 +39,17 @@ func ReadSeedFile(ctx context.Context, path string) ([]*model.ServerRecord, erro } // Parse extension wrapper format (only supported format) - var serverResponses []model.ServerResponse + var serverResponses []apiv1.ServerRecord 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 []*model.ServerRecord{}, nil + return []*apiv1.ServerRecord{}, nil } // Convert ServerResponse to ServerRecord - var records []*model.ServerRecord + var records []*apiv1.ServerRecord for _, response := range serverResponses { record := convertServerResponseToRecord(response) records = append(records, record) @@ -78,8 +77,8 @@ func fetchFromHTTP(ctx context.Context, url string) ([]byte, error) { return io.ReadAll(resp.Body) } -func fetchFromRegistryAPI(ctx context.Context, baseURL string) ([]*model.ServerRecord, error) { - var allRecords []*model.ServerRecord +func fetchFromRegistryAPI(ctx context.Context, baseURL string) ([]*apiv1.ServerRecord, error) { + var allRecords []*apiv1.ServerRecord cursor := "" for { @@ -98,7 +97,7 @@ func fetchFromRegistryAPI(ctx context.Context, baseURL string) ([]*model.ServerR } var response struct { - Servers []model.ServerResponse `json:"servers"` + Servers []apiv1.ServerRecord `json:"servers"` Metadata *struct { NextCursor string `json:"next_cursor,omitempty"` } `json:"metadata,omitempty"` @@ -124,55 +123,20 @@ func fetchFromRegistryAPI(ctx context.Context, baseURL string) ([]*model.ServerR return allRecords, nil } -func convertServerResponseToRecord(response model.ServerResponse) *model.ServerRecord { - // Extract registry metadata from the extension - registryExt := response.XIOModelContextProtocolRegistry - - // Parse timestamps - publishedAt, _ := time.Parse(time.RFC3339, getStringFromInterface(registryExt, "published_at")) - updatedAt, _ := time.Parse(time.RFC3339, getStringFromInterface(registryExt, "updated_at")) - - registryMetadata := model.RegistryMetadata{ - ID: getStringFromInterface(registryExt, "id"), - IsLatest: getBoolFromInterface(registryExt, "is_latest"), - PublishedAt: publishedAt, - UpdatedAt: updatedAt, - ReleaseDate: getStringFromInterface(registryExt, "release_date"), - } +func convertServerResponseToRecord(response apiv1.ServerRecord) *apiv1.ServerRecord { + // The registry extensions are already properly typed, so we can use them directly + registryMetadata := response.XIOModelContextProtocolRegistry // Publisher extensions - publisherExtensions := make(map[string]interface{}) - if response.XPublisher != nil { - if publisherMap, ok := response.XPublisher.(map[string]interface{}); ok { - publisherExtensions = publisherMap - } + publisherExtensions := response.XPublisher + if publisherExtensions == nil { + publisherExtensions = make(map[string]interface{}) } - return &model.ServerRecord{ - ServerJSON: response.Server, - RegistryMetadata: registryMetadata, - PublisherExtensions: publisherExtensions, + return &apiv1.ServerRecord{ + Server: response.Server, + XIOModelContextProtocolRegistry: registryMetadata, + XPublisher: publisherExtensions, } } -func getStringFromInterface(data interface{}, key string) string { - if dataMap, ok := data.(map[string]interface{}); ok { - if value, exists := dataMap[key]; exists { - if strValue, ok := value.(string); ok { - return strValue - } - } - } - return "" -} - -func getBoolFromInterface(data interface{}, key string) bool { - if dataMap, ok := data.(map[string]interface{}); ok { - if value, exists := dataMap[key]; exists { - if boolValue, ok := value.(bool); ok { - return boolValue - } - } - } - return false -} \ No newline at end of file diff --git a/internal/database/import_test.go b/internal/database/import_test.go index 6f11d83..645327f 100644 --- a/internal/database/import_test.go +++ b/internal/database/import_test.go @@ -7,18 +7,20 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/modelcontextprotocol/registry/internal/database" - "github.com/modelcontextprotocol/registry/internal/model" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "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 := []model.ServerResponse{ + seedData := []apiv1.ServerRecord{ { - Server: model.ServerDetail{ + Server: model.ServerJSON{ Name: "test-server-1", Description: "Test server 1", Repository: model.Repository{ @@ -30,10 +32,11 @@ func TestReadSeedFile_LocalFile(t *testing.T) { Version: "1.0.0", }, }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": "test-id-1", - "published_at": "2023-01-01T00:00:00Z", - "is_latest": true, + XIOModelContextProtocolRegistry: apiv1.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", }, }, } @@ -58,21 +61,22 @@ func TestReadSeedFile_LocalFile(t *testing.T) { result, err := database.ReadSeedFile(context.Background(), tempFile) assert.NoError(t, err) assert.Len(t, result, 1) - assert.Equal(t, "test-server-1", result[0].ServerJSON.Name) + assert.Equal(t, "test-server-1", result[0].Server.Name) } func TestReadSeedFile_DirectHTTPURL(t *testing.T) { // Create a test HTTP server that serves seed JSON directly in extension wrapper format - seedData := []model.ServerResponse{ + seedData := []apiv1.ServerRecord{ { - Server: model.ServerDetail{ + Server: model.ServerJSON{ Name: "test-server-1", Description: "Test server 1", }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": "test-id-1", - "published_at": "2023-01-01T00:00:00Z", - "is_latest": true, + XIOModelContextProtocolRegistry: apiv1.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", }, }, } @@ -89,13 +93,13 @@ func TestReadSeedFile_DirectHTTPURL(t *testing.T) { result, err := database.ReadSeedFile(context.Background(), server.URL+"/seed.json") assert.NoError(t, err) assert.Len(t, result, 1) - assert.Equal(t, "test-server-1", result[0].ServerJSON.Name) + assert.Equal(t, "test-server-1", result[0].Server.Name) } func TestReadSeedFile_RegistryURL(t *testing.T) { // Create mock registry responses - server1 := model.ServerResponse{ - Server: model.ServerDetail{ + server1 := apiv1.ServerRecord{ + Server: model.ServerJSON{ Name: "Test Server 1", Description: "First test server", Packages: []model.Package{ @@ -107,14 +111,15 @@ func TestReadSeedFile_RegistryURL(t *testing.T) { }, }, }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": "server-1", - "published_at": "2023-01-01T00:00:00Z", - "is_latest": true, + XIOModelContextProtocolRegistry: apiv1.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 := model.ServerResponse{ - Server: model.ServerDetail{ + server2 := apiv1.ServerRecord{ + Server: model.ServerJSON{ Name: "Test Server 2", Description: "Second test server", Packages: []model.Package{ @@ -126,14 +131,15 @@ func TestReadSeedFile_RegistryURL(t *testing.T) { }, }, }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": "server-2", - "published_at": "2023-01-01T00:00:00Z", - "is_latest": true, + XIOModelContextProtocolRegistry: apiv1.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 := model.ServerDetail{ + serverDetail1 := model.ServerJSON{ Name: "Test Server 1", Description: "First test server", Packages: []model.Package{ @@ -145,7 +151,7 @@ func TestReadSeedFile_RegistryURL(t *testing.T) { }, }, } - serverDetail2 := model.ServerDetail{ + serverDetail2 := model.ServerJSON{ Name: "Test Server 2", Description: "Second test server", Packages: []model.Package{ @@ -171,8 +177,8 @@ func TestReadSeedFile_RegistryURL(t *testing.T) { } type PaginatedResponse struct { - Servers []model.ServerResponse `json:"servers"` - Metadata *Metadata `json:"metadata,omitempty"` + Servers []apiv1.ServerRecord `json:"servers"` + Metadata *Metadata `json:"metadata,omitempty"` } var response PaginatedResponse @@ -180,7 +186,7 @@ func TestReadSeedFile_RegistryURL(t *testing.T) { case "": // First page response = PaginatedResponse{ - Servers: []model.ServerResponse{server1}, + Servers: []apiv1.ServerRecord{server1}, Metadata: &Metadata{ NextCursor: "next-cursor-1", Count: 1, @@ -189,7 +195,7 @@ func TestReadSeedFile_RegistryURL(t *testing.T) { case "next-cursor-1": // Second page response = PaginatedResponse{ - Servers: []model.ServerResponse{server2}, + Servers: []apiv1.ServerRecord{server2}, Metadata: &Metadata{ Count: 1, // No NextCursor means end of pagination @@ -198,7 +204,7 @@ func TestReadSeedFile_RegistryURL(t *testing.T) { default: // No more pages response = PaginatedResponse{ - Servers: []model.ServerResponse{}, + Servers: []apiv1.ServerRecord{}, Metadata: &Metadata{}, } } @@ -233,20 +239,20 @@ func TestReadSeedFile_RegistryURL(t *testing.T) { assert.Len(t, result, 2) // Verify the servers were imported correctly - assert.Equal(t, "Test Server 1", result[0].ServerJSON.Name) - assert.Equal(t, "Test Server 2", result[1].ServerJSON.Name) + assert.Equal(t, "Test Server 1", result[0].Server.Name) + assert.Equal(t, "Test Server 2", result[1].Server.Name) // Verify packages were imported with new schema - assert.Len(t, result[0].ServerJSON.Packages, 1) - assert.Equal(t, "npm", result[0].ServerJSON.Packages[0].RegistryType) - assert.Equal(t, "https://registry.npmjs.org", result[0].ServerJSON.Packages[0].RegistryBaseURL) - assert.Equal(t, "test-package-1", result[0].ServerJSON.Packages[0].Identifier) - assert.Len(t, result[1].ServerJSON.Packages, 1) - assert.Equal(t, "npm", result[1].ServerJSON.Packages[0].RegistryType) - assert.Equal(t, "https://registry.npmjs.org", result[1].ServerJSON.Packages[0].RegistryBaseURL) - assert.Equal(t, "test-package-2", result[1].ServerJSON.Packages[0].Identifier) + assert.Len(t, result[0].Server.Packages, 1) + assert.Equal(t, "npm", result[0].Server.Packages[0].RegistryType) + assert.Equal(t, "https://registry.npmjs.org", result[0].Server.Packages[0].RegistryBaseURL) + assert.Equal(t, "test-package-1", result[0].Server.Packages[0].Identifier) + assert.Len(t, result[1].Server.Packages, 1) + assert.Equal(t, "npm", result[1].Server.Packages[0].RegistryType) + assert.Equal(t, "https://registry.npmjs.org", result[1].Server.Packages[0].RegistryBaseURL) + assert.Equal(t, "test-package-2", result[1].Server.Packages[0].Identifier) // Verify metadata was extracted - assert.Equal(t, "server-1", result[0].RegistryMetadata.ID) - assert.Equal(t, "server-2", result[1].RegistryMetadata.ID) -} \ No newline at end of file + assert.Equal(t, "server-1", result[0].XIOModelContextProtocolRegistry.ID) + assert.Equal(t, "server-2", result[1].XIOModelContextProtocolRegistry.ID) +} diff --git a/internal/database/memory.go b/internal/database/memory.go index 2f36dba..bdb7221 100644 --- a/internal/database/memory.go +++ b/internal/database/memory.go @@ -7,32 +7,33 @@ import ( "sync" "time" - "github.com/modelcontextprotocol/registry/internal/model" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" ) // MemoryDB is an in-memory implementation of the Database interface type MemoryDB struct { - entries map[string]*model.ServerRecord // maps registry metadata ID to ServerRecord + entries map[string]*apiv1.ServerRecord // maps registry metadata ID to ServerRecord mu sync.RWMutex } // NewMemoryDB creates a new instance of the in-memory database -func NewMemoryDB(e map[string]*model.ServerDetail) *MemoryDB { - // Convert ServerDetail entries to ServerRecord entries - serverRecords := make(map[string]*model.ServerRecord) +func NewMemoryDB(e map[string]*model.ServerJSON) *MemoryDB { + // Convert ServerJSON entries to ServerRecord entries + serverRecords := make(map[string]*apiv1.ServerRecord) for registryID, serverDetail := range e { // Create registry metadata now := time.Now() - record := &model.ServerRecord{ - ServerJSON: *serverDetail, - RegistryMetadata: model.RegistryMetadata{ + record := &apiv1.ServerRecord{ + Server: *serverDetail, + XIOModelContextProtocolRegistry: apiv1.RegistryExtensions{ ID: registryID, PublishedAt: now, UpdatedAt: now, IsLatest: true, ReleaseDate: now.Format(time.RFC3339), }, - PublisherExtensions: make(map[string]interface{}), + XPublisher: make(map[string]interface{}), } serverRecords[registryID] = record } @@ -41,14 +42,13 @@ func NewMemoryDB(e map[string]*model.ServerDetail) *MemoryDB { } } - //nolint:cyclop // Complexity from filtering logic is acceptable for memory implementation func (db *MemoryDB) List( ctx context.Context, filter map[string]any, cursor string, limit int, -) ([]*model.ServerRecord, string, error) { +) ([]*apiv1.ServerRecord, string, error) { if ctx.Err() != nil { return nil, "", ctx.Err() } @@ -59,18 +59,17 @@ func (db *MemoryDB) List( db.mu.RLock() defer db.mu.RUnlock() - // Convert all entries to a slice for pagination, filter by is_latest - var allEntries []*model.ServerRecord + var allEntries []*apiv1.ServerRecord for _, entry := range db.entries { - if entry.RegistryMetadata.IsLatest { + if entry.XIOModelContextProtocolRegistry.IsLatest { allEntries = append(allEntries, entry) } } // Simple filtering implementation - var filteredEntries []*model.ServerRecord + var filteredEntries []*apiv1.ServerRecord for _, entry := range allEntries { include := true @@ -78,21 +77,21 @@ func (db *MemoryDB) List( for key, value := range filter { switch key { case "name": - if entry.ServerJSON.Name != value.(string) { + if entry.Server.Name != value.(string) { include = false } case "version": - if entry.ServerJSON.VersionDetail.Version != value.(string) { + if entry.Server.VersionDetail.Version != value.(string) { include = false } case "status": - if string(entry.ServerJSON.Status) != value.(string) { + if string(entry.Server.Status) != value.(string) { include = false } case "remote_url": found := false remoteURL := value.(string) - for _, remote := range entry.ServerJSON.Remotes { + for _, remote := range entry.Server.Remotes { if remote.URL == remoteURL { found = true break @@ -111,14 +110,14 @@ func (db *MemoryDB) List( // Sort filteredEntries by registry metadata ID for consistent pagination sort.Slice(filteredEntries, func(i, j int) bool { - return filteredEntries[i].RegistryMetadata.ID < filteredEntries[j].RegistryMetadata.ID + return filteredEntries[i].XIOModelContextProtocolRegistry.ID < filteredEntries[j].XIOModelContextProtocolRegistry.ID }) // Find starting point for cursor-based pagination using registry metadata ID startIdx := 0 if cursor != "" { for i, entry := range filteredEntries { - if entry.RegistryMetadata.ID == cursor { + if entry.XIOModelContextProtocolRegistry.ID == cursor { startIdx = i + 1 // Start after the cursor break } @@ -128,24 +127,24 @@ func (db *MemoryDB) List( // Apply pagination endIdx := min(startIdx+limit, len(filteredEntries)) - var result []*model.ServerRecord + var result []*apiv1.ServerRecord if startIdx < len(filteredEntries) { result = filteredEntries[startIdx:endIdx] } else { - result = []*model.ServerRecord{} + result = []*apiv1.ServerRecord{} } // Determine next cursor using registry metadata ID nextCursor := "" if endIdx < len(filteredEntries) { - nextCursor = filteredEntries[endIdx-1].RegistryMetadata.ID + nextCursor = filteredEntries[endIdx-1].XIOModelContextProtocolRegistry.ID } return result, nextCursor, nil } // GetByID retrieves a single ServerRecord by its registry metadata ID -func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerRecord, error) { +func (db *MemoryDB) GetByID(ctx context.Context, id string) (*apiv1.ServerRecord, error) { if ctx.Err() != nil { return nil, ctx.Err() } @@ -164,17 +163,17 @@ func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerRecord } // Publish adds a new server to the database with separated server.json and extensions -func (db *MemoryDB) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, registryMetadata model.RegistryMetadata) (*model.ServerRecord, error) { +func (db *MemoryDB) Publish(ctx context.Context, serverDetail model.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv1.RegistryExtensions) (*apiv1.ServerRecord, error) { if ctx.Err() != nil { return nil, ctx.Err() } - + // Extract name and version for validation name := serverDetail.Name if name == "" { return nil, fmt.Errorf("name is required in server JSON") } - + version := serverDetail.VersionDetail.Version if version == "" { return nil, fmt.Errorf("version is required in version_detail") @@ -189,10 +188,10 @@ func (db *MemoryDB) Publish(ctx context.Context, serverDetail model.ServerDetail } // Create server record - record := &model.ServerRecord{ - ServerJSON: serverDetail, - RegistryMetadata: registryMetadata, - PublisherExtensions: publisherExtensions, + record := &apiv1.ServerRecord{ + Server: serverDetail, + XIOModelContextProtocolRegistry: registryMetadata, + XPublisher: publisherExtensions, } // Store the record using registry metadata ID @@ -206,7 +205,7 @@ 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) @@ -221,9 +220,8 @@ func (db *MemoryDB) ImportSeed(ctx context.Context, seedFilePath string) error { // Import each server for _, record := range seedData { // Use the registry metadata ID as the map key - db.entries[record.RegistryMetadata.ID] = record + db.entries[record.XIOModelContextProtocolRegistry.ID] = record } - return nil } @@ -238,8 +236,8 @@ func (db *MemoryDB) UpdateLatestFlag(ctx context.Context, id string, isLatest bo defer db.mu.Unlock() if entry, exists := db.entries[id]; exists { - entry.RegistryMetadata.IsLatest = isLatest - entry.RegistryMetadata.UpdatedAt = time.Now() + entry.XIOModelContextProtocolRegistry.IsLatest = isLatest + entry.XIOModelContextProtocolRegistry.UpdatedAt = time.Now() return nil } @@ -247,7 +245,7 @@ func (db *MemoryDB) UpdateLatestFlag(ctx context.Context, id string, isLatest bo } // UpdateServer updates an existing server record with new server details -func (db *MemoryDB) UpdateServer(ctx context.Context, id string, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error) { +func (db *MemoryDB) UpdateServer(ctx context.Context, id string, serverDetail model.ServerJSON, publisherExtensions map[string]interface{}) (*apiv1.ServerRecord, error) { if ctx.Err() != nil { return nil, ctx.Err() } @@ -261,9 +259,9 @@ func (db *MemoryDB) UpdateServer(ctx context.Context, id string, serverDetail mo } // Update the server details - entry.ServerJSON = serverDetail - entry.PublisherExtensions = publisherExtensions - entry.RegistryMetadata.UpdatedAt = time.Now() + entry.Server = serverDetail + entry.XPublisher = publisherExtensions + entry.XIOModelContextProtocolRegistry.UpdatedAt = time.Now() // Return the updated record return entry, nil @@ -282,4 +280,4 @@ func (db *MemoryDB) Connection() *ConnectionInfo { IsConnected: true, // Memory DB is always connected Raw: db.entries, } -} \ No newline at end of file +} diff --git a/internal/database/postgres.go b/internal/database/postgres.go index e012f08..e8ca584 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -10,7 +10,8 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" - "github.com/modelcontextprotocol/registry/internal/model" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" ) // PostgreSQL is an implementation of the Database interface using PostgreSQL @@ -47,7 +48,7 @@ func (db *PostgreSQL) List( filter map[string]any, cursor string, limit int, -) ([]*model.ServerRecord, string, error) { +) ([]*apiv1.ServerRecord, string, error) { if limit <= 0 { limit = 10 } @@ -112,26 +113,26 @@ func (db *PostgreSQL) List( } defer rows.Close() - var results []*model.ServerRecord + var results []*apiv1.ServerRecord for rows.Next() { - var record model.ServerRecord + var record apiv1.ServerRecord var repositoryJSON, packagesJSON, remotesJSON, publisherExtensionsJSON []byte var publishedAt, updatedAt, releaseDate time.Time err := rows.Scan( // Server fields - &record.ServerJSON.Name, - &record.ServerJSON.Description, - &record.ServerJSON.Status, + &record.Server.Name, + &record.Server.Description, + &record.Server.Status, &repositoryJSON, - &record.ServerJSON.VersionDetail.Version, + &record.Server.VersionDetail.Version, &packagesJSON, &remotesJSON, // Registry metadata fields - &record.RegistryMetadata.ID, + &record.XIOModelContextProtocolRegistry.ID, &publishedAt, &updatedAt, - &record.RegistryMetadata.IsLatest, + &record.XIOModelContextProtocolRegistry.IsLatest, &releaseDate, &publisherExtensionsJSON, ) @@ -145,9 +146,9 @@ func (db *PostgreSQL) List( } // Set registry metadata timestamps - record.RegistryMetadata.PublishedAt = publishedAt - record.RegistryMetadata.UpdatedAt = updatedAt - record.RegistryMetadata.ReleaseDate = releaseDate.Format(time.RFC3339) + record.XIOModelContextProtocolRegistry.PublishedAt = publishedAt + record.XIOModelContextProtocolRegistry.UpdatedAt = updatedAt + record.XIOModelContextProtocolRegistry.ReleaseDate = releaseDate.Format(time.RFC3339) results = append(results, &record) } @@ -159,45 +160,45 @@ func (db *PostgreSQL) List( // Determine next cursor using registry metadata ID nextCursor := "" if len(results) > 0 && len(results) >= limit { - nextCursor = results[len(results)-1].RegistryMetadata.ID + nextCursor = results[len(results)-1].XIOModelContextProtocolRegistry.ID } return results, nextCursor, nil } // parseJSONFields parses JSON fields for a server record -func parseJSONFields(record *model.ServerRecord, repositoryJSON, packagesJSON, remotesJSON, publisherExtensionsJSON []byte) error { +func parseJSONFields(record *apiv1.ServerRecord, repositoryJSON, packagesJSON, remotesJSON, publisherExtensionsJSON []byte) error { if len(repositoryJSON) > 0 { - if err := json.Unmarshal(repositoryJSON, &record.ServerJSON.Repository); err != nil { + if err := json.Unmarshal(repositoryJSON, &record.Server.Repository); err != nil { return fmt.Errorf("failed to unmarshal repository: %w", err) } } if len(packagesJSON) > 0 { - if err := json.Unmarshal(packagesJSON, &record.ServerJSON.Packages); err != nil { + if err := json.Unmarshal(packagesJSON, &record.Server.Packages); err != nil { return fmt.Errorf("failed to unmarshal packages: %w", err) } } if len(remotesJSON) > 0 { - if err := json.Unmarshal(remotesJSON, &record.ServerJSON.Remotes); err != nil { + if err := json.Unmarshal(remotesJSON, &record.Server.Remotes); err != nil { return fmt.Errorf("failed to unmarshal remotes: %w", err) } } if len(publisherExtensionsJSON) > 0 { - if err := json.Unmarshal(publisherExtensionsJSON, &record.PublisherExtensions); err != nil { + if err := json.Unmarshal(publisherExtensionsJSON, &record.XPublisher); err != nil { return fmt.Errorf("failed to unmarshal publisher extensions: %w", err) } } else { - record.PublisherExtensions = make(map[string]interface{}) + record.XPublisher = make(map[string]interface{}) } return nil } // GetByID retrieves a single ServerRecord by its registry metadata ID -func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*model.ServerRecord, error) { +func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*apiv1.ServerRecord, error) { if ctx.Err() != nil { return nil, ctx.Err() } @@ -211,24 +212,24 @@ func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*model.ServerReco WHERE se.id = $1 ` - var record model.ServerRecord + var record apiv1.ServerRecord var repositoryJSON, packagesJSON, remotesJSON, publisherExtensionsJSON []byte var publishedAt, updatedAt, releaseDate time.Time err := db.conn.QueryRow(ctx, query, id).Scan( // Server fields - &record.ServerJSON.Name, - &record.ServerJSON.Description, - &record.ServerJSON.Status, + &record.Server.Name, + &record.Server.Description, + &record.Server.Status, &repositoryJSON, - &record.ServerJSON.VersionDetail.Version, + &record.Server.VersionDetail.Version, &packagesJSON, &remotesJSON, // Registry metadata fields - &record.RegistryMetadata.ID, + &record.XIOModelContextProtocolRegistry.ID, &publishedAt, &updatedAt, - &record.RegistryMetadata.IsLatest, + &record.XIOModelContextProtocolRegistry.IsLatest, &releaseDate, &publisherExtensionsJSON, ) @@ -242,42 +243,42 @@ func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*model.ServerReco // Parse JSON fields if len(repositoryJSON) > 0 { - if err := json.Unmarshal(repositoryJSON, &record.ServerJSON.Repository); err != nil { + if err := json.Unmarshal(repositoryJSON, &record.Server.Repository); err != nil { return nil, fmt.Errorf("failed to unmarshal repository: %w", err) } } if len(packagesJSON) > 0 { - if err := json.Unmarshal(packagesJSON, &record.ServerJSON.Packages); err != nil { + if err := json.Unmarshal(packagesJSON, &record.Server.Packages); err != nil { return nil, fmt.Errorf("failed to unmarshal packages: %w", err) } } if len(remotesJSON) > 0 { - if err := json.Unmarshal(remotesJSON, &record.ServerJSON.Remotes); err != nil { + if err := json.Unmarshal(remotesJSON, &record.Server.Remotes); err != nil { return nil, fmt.Errorf("failed to unmarshal remotes: %w", err) } } // Parse publisher extensions if len(publisherExtensionsJSON) > 0 { - if err := json.Unmarshal(publisherExtensionsJSON, &record.PublisherExtensions); err != nil { + if err := json.Unmarshal(publisherExtensionsJSON, &record.XPublisher); err != nil { return nil, fmt.Errorf("failed to unmarshal publisher extensions: %w", err) } } else { - record.PublisherExtensions = make(map[string]interface{}) + record.XPublisher = make(map[string]interface{}) } // Set registry metadata timestamps - record.RegistryMetadata.PublishedAt = publishedAt - record.RegistryMetadata.UpdatedAt = updatedAt - record.RegistryMetadata.ReleaseDate = releaseDate.Format(time.RFC3339) + record.XIOModelContextProtocolRegistry.PublishedAt = publishedAt + record.XIOModelContextProtocolRegistry.UpdatedAt = updatedAt + record.XIOModelContextProtocolRegistry.ReleaseDate = releaseDate.Format(time.RFC3339) return &record, nil } // Publish adds a new server to the database with separated server.json and extensions -func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, registryMetadata model.RegistryMetadata) (*model.ServerRecord, error) { +func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv1.RegistryExtensions) (*apiv1.ServerRecord, error) { if ctx.Err() != nil { return nil, ctx.Err() } @@ -292,7 +293,6 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta } }() - // Prepare JSON data for server table repositoryJSON, err := json.Marshal(serverDetail.Repository) if err != nil { @@ -317,7 +317,6 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta // Use the same ID for both server and server_extensions records (1:1 relationship) serverID := registryMetadata.ID - // Insert new server record insertServerQuery := ` INSERT INTO servers (id, name, description, status, repository, version, packages, remotes) @@ -348,7 +347,7 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta registryMetadata.PublishedAt, registryMetadata.UpdatedAt, registryMetadata.IsLatest, - registryMetadata.PublishedAt, // release_date + registryMetadata.PublishedAt, // release_date publisherExtensionsJSON, ) if err != nil { @@ -361,10 +360,10 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta } // Create and return the ServerRecord - record := &model.ServerRecord{ - ServerJSON: serverDetail, - RegistryMetadata: registryMetadata, - PublisherExtensions: publisherExtensions, + record := &apiv1.ServerRecord{ + Server: serverDetail, + XIOModelContextProtocolRegistry: registryMetadata, + XPublisher: publisherExtensions, } return record, nil @@ -392,11 +391,11 @@ func (db *PostgreSQL) ImportSeed(ctx context.Context, seedFilePath string) error // Import each server for _, record := range seedData { // Convert record to the format expected by Publish - serverDetail := record.ServerJSON - publisherExtensions := record.PublisherExtensions + serverDetail := record.Server + publisherExtensions := record.XPublisher // Use the existing Publish logic but with specific ID from seed data - if err := db.publishWithTransaction(ctx, tx, serverDetail, publisherExtensions, &record.RegistryMetadata); err != nil { + if err := db.publishWithTransaction(ctx, tx, serverDetail, publisherExtensions, &record.XIOModelContextProtocolRegistry); err != nil { return fmt.Errorf("failed to import server %s: %w", serverDetail.Name, err) } } @@ -410,7 +409,7 @@ func (db *PostgreSQL) ImportSeed(ctx context.Context, seedFilePath string) error } // publishWithTransaction handles publishing within an existing transaction, optionally with predefined metadata -func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, existingMetadata *model.RegistryMetadata) error { +func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, serverDetail model.ServerJSON, publisherExtensions map[string]interface{}, existingMetadata *apiv1.RegistryExtensions) error { // Use the same ID for both server and server_extensions (1:1 relationship) var id string if existingMetadata != nil && existingMetadata.ID != "" { @@ -518,7 +517,7 @@ func (db *PostgreSQL) UpdateLatestFlag(ctx context.Context, id string, isLatest SET is_latest = $1, updated_at = $2 WHERE id = $3 ` - + result, err := db.conn.Exec(ctx, query, isLatest, time.Now(), id) if err != nil { return fmt.Errorf("failed to update latest flag: %w", err) @@ -532,7 +531,7 @@ func (db *PostgreSQL) UpdateLatestFlag(ctx context.Context, id string, isLatest } // UpdateServer updates an existing server record with new server details -func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error) { +func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, serverDetail model.ServerJSON, publisherExtensions map[string]interface{}) (*apiv1.ServerRecord, error) { if ctx.Err() != nil { return nil, ctx.Err() } @@ -641,4 +640,4 @@ func (db *PostgreSQL) Connection() *ConnectionInfo { IsConnected: isConnected, Raw: db.conn, } -} \ No newline at end of file +} diff --git a/internal/model/model.go b/internal/model/model.go deleted file mode 100644 index adca232..0000000 --- a/internal/model/model.go +++ /dev/null @@ -1,364 +0,0 @@ -package model - -import ( - "encoding/json" - "fmt" - "net/url" - "slices" - "strings" - "time" -) - -// AuthMethod represents the authentication method used -type AuthMethod string - -const ( - // GitHub OAuth authentication (access token) - AuthMethodGitHubAT AuthMethod = "github-at" - // GitHub Actions OIDC authentication - AuthMethodGitHubOIDC AuthMethod = "github-oidc" - // Generic OIDC authentication - AuthMethodOIDC AuthMethod = "oidc" - // DNS-based public/private key authentication - AuthMethodDNS AuthMethod = "dns" - // HTTP-based public/private key authentication - AuthMethodHTTP AuthMethod = "http" - // No authentication - should only be used for local development and testing - AuthMethodNone AuthMethod = "none" -) - -// ServerStatus represents the lifecycle status of a server -type ServerStatus string - -const ( - ServerStatusActive ServerStatus = "active" - ServerStatusDeprecated ServerStatus = "deprecated" - ServerStatusDeleted ServerStatus = "deleted" -) - -// Repository represents a source code repository as defined in the spec -type Repository struct { - URL string `json:"url" bson:"url"` - Source string `json:"source" bson:"source"` - ID string `json:"id,omitempty" bson:"id,omitempty"` -} - -// create an enum for Format -type Format string - -const ( - FormatString Format = "string" - FormatNumber Format = "number" - FormatBoolean Format = "boolean" - FormatFilePath Format = "file_path" -) - -type Input struct { - Description string `json:"description,omitempty" bson:"description,omitempty"` - IsRequired bool `json:"is_required,omitempty" bson:"is_required,omitempty"` - Format Format `json:"format,omitempty" bson:"format,omitempty"` - Value string `json:"value,omitempty" bson:"value,omitempty"` - IsSecret bool `json:"is_secret,omitempty" bson:"is_secret,omitempty"` - Default string `json:"default,omitempty" bson:"default,omitempty"` - Choices []string `json:"choices,omitempty" bson:"choices,omitempty"` -} - -type InputWithVariables struct { - Input `json:",inline" bson:",inline"` - Variables map[string]Input `json:"variables,omitempty" bson:"variables,omitempty"` -} - -type KeyValueInput struct { - InputWithVariables `json:",inline" bson:",inline"` - Name string `json:"name" bson:"name"` -} -type ArgumentType string - -const ( - ArgumentTypePositional ArgumentType = "positional" - ArgumentTypeNamed ArgumentType = "named" -) - -// RuntimeArgument defines a type that can be either a PositionalArgument or a NamedArgument -type Argument struct { - InputWithVariables `json:",inline" bson:",inline"` - Type ArgumentType `json:"type" bson:"type"` - Name string `json:"name,omitempty" bson:"name,omitempty"` - IsRepeated bool `json:"is_repeated,omitempty" bson:"is_repeated,omitempty"` - ValueHint string `json:"value_hint,omitempty" bson:"value_hint,omitempty"` -} - -type Package struct { - // RegistryType indicates how to download packages (e.g., "npm", "pypi", "docker-hub", "mcpb") - RegistryType string `json:"registry_type,omitempty" bson:"registry_type,omitempty"` - // RegistryBaseURL is the base URL of the package registry - RegistryBaseURL string `json:"registry_base_url,omitempty" bson:"registry_base_url,omitempty"` - // Identifier is the package identifier - either a package name (for registries) or URL (for direct downloads) - Identifier string `json:"identifier,omitempty" bson:"identifier,omitempty"` - Version string `json:"version,omitempty" bson:"version,omitempty"` - FileSHA256 string `json:"file_sha256,omitempty" bson:"file_sha256,omitempty"` - RunTimeHint string `json:"runtime_hint,omitempty" bson:"runtime_hint,omitempty"` - RuntimeArguments []Argument `json:"runtime_arguments,omitempty" bson:"runtime_arguments,omitempty"` - PackageArguments []Argument `json:"package_arguments,omitempty" bson:"package_arguments,omitempty"` - EnvironmentVariables []KeyValueInput `json:"environment_variables,omitempty" bson:"environment_variables,omitempty"` -} - -// Remote represents a remote connection endpoint -type Remote struct { - TransportType string `json:"transport_type" bson:"transport_type"` - URL string `json:"url" format:"uri" bson:"url"` - Headers []KeyValueInput `json:"headers,omitempty" bson:"headers,omitempty"` -} - -// VersionDetail represents the version details of a server (pure MCP spec, no registry metadata) -type VersionDetail struct { - Version string `json:"version" bson:"version"` -} - -// ServerDetail represents complete server information as defined in the MCP spec (pure, no registry metadata) -type ServerDetail struct { - Schema string `json:"$schema,omitempty" bson:"$schema,omitempty"` - Name string `json:"name" minLength:"1" maxLength:"200" bson:"name"` - Description string `json:"description" minLength:"1" maxLength:"100" bson:"description"` - Status ServerStatus `json:"status,omitempty" minLength:"1" bson:"status,omitempty"` - Repository Repository `json:"repository,omitempty" bson:"repository"` - VersionDetail VersionDetail `json:"version_detail" bson:"version_detail"` - Packages []Package `json:"packages,omitempty" bson:"packages,omitempty"` - Remotes []Remote `json:"remotes,omitempty" bson:"remotes,omitempty"` -} - -// RegistryMetadata represents registry-generated metadata -type RegistryMetadata struct { - ID string `json:"id" bson:"_id"` - PublishedAt time.Time `json:"published_at" bson:"published_at"` - UpdatedAt time.Time `json:"updated_at,omitempty" bson:"updated_at,omitempty"` - IsLatest bool `json:"is_latest" bson:"is_latest"` - ReleaseDate string `json:"release_date" bson:"release_date"` -} - -// ServerRecord represents the complete storage model that separates server.json from registry metadata -type ServerRecord struct { - ServerJSON ServerDetail `json:"server" bson:"server"` // Pure MCP server.json - RegistryMetadata RegistryMetadata `json:"registry_metadata" bson:"registry_metadata"` // Registry-generated data - PublisherExtensions map[string]interface{} `json:"publisher_extensions" bson:"publisher_extensions"` // x-publisher extensions -} - -// ServerResponse represents the API response format with wrapper and extensions -type ServerResponse struct { - Server ServerDetail `json:"server"` - XIOModelContextProtocolRegistry interface{} `json:"x-io.modelcontextprotocol.registry,omitempty"` - XPublisher interface{} `json:"x-publisher,omitempty"` -} - -// ServerListResponse represents the paginated server list response -type ServerListResponse struct { - Servers []ServerResponse `json:"servers"` - Metadata *Metadata `json:"metadata,omitempty"` -} - -// PublishRequest represents the API request format for publishing servers -type PublishRequest struct { - Server ServerDetail `json:"server"` - XPublisher interface{} `json:"x-publisher,omitempty"` -} - -// Metadata represents pagination metadata -type Metadata struct { - NextCursor string `json:"next_cursor,omitempty"` - Count int `json:"count,omitempty"` - Total int `json:"total,omitempty"` -} - -// Helper functions - -// ValidatePublisherExtensions validates that publisher extensions are within size limits -func ValidatePublisherExtensions(req PublishRequest) error { - const maxExtensionSize = 4 * 1024 // 4KB limit - - // Check size limit for x-publisher extension - if req.XPublisher != nil { - extensionsJSON, err := json.Marshal(req.XPublisher) - if err != nil { - return fmt.Errorf("failed to marshal x-publisher extension: %w", err) - } - if len(extensionsJSON) > maxExtensionSize { - return fmt.Errorf("x-publisher extension exceeds 4KB limit (%d bytes)", len(extensionsJSON)) - } - } - - return nil -} - -// ValidatePublishRequestExtensions validates that only allowed extension fields are present -func ValidatePublishRequestExtensions(requestData []byte) error { - // Parse the raw JSON to check for unknown fields - var rawRequest map[string]interface{} - if err := json.Unmarshal(requestData, &rawRequest); err != nil { - return fmt.Errorf("failed to parse request JSON: %w", err) - } - - // Define allowed top-level fields - allowedFields := map[string]bool{ - "server": true, - "x-publisher": true, - } - - // Check for any disallowed fields - var invalidFields []string - for field := range rawRequest { - if !allowedFields[field] { - invalidFields = append(invalidFields, field) - } - } - - if len(invalidFields) > 0 { - return fmt.Errorf("invalid extension fields: %v. Only 'server' and 'x-publisher' fields are allowed", invalidFields) - } - - return nil -} - -// ExtractPublisherExtensions extracts publisher extensions from a PublishRequest -func ExtractPublisherExtensions(req PublishRequest) map[string]interface{} { - publisherExtensions := make(map[string]interface{}) - if req.XPublisher != nil { - // Cast to map and copy fields directly, avoiding double nesting - if publisherMap, ok := req.XPublisher.(map[string]interface{}); ok { - for k, v := range publisherMap { - publisherExtensions[k] = v - } - } - } - return publisherExtensions -} - -// CreateRegistryExtensions generates the x-io.modelcontextprotocol.registry extension from registry metadata -func (rm *RegistryMetadata) CreateRegistryExtensions() map[string]interface{} { - return map[string]interface{}{ - "x-io.modelcontextprotocol.registry": map[string]interface{}{ - "id": rm.ID, - "published_at": rm.PublishedAt, - "updated_at": rm.UpdatedAt, - "is_latest": rm.IsLatest, - "release_date": rm.ReleaseDate, - }, - } -} - -// ParseServerName extracts the server name from a ServerDetail for validation purposes -func ParseServerName(serverDetail ServerDetail) (string, error) { - name := serverDetail.Name - if name == "" { - return "", fmt.Errorf("server name is required and must be a string") - } - - // Validate format: dns-namespace/name - if !strings.Contains(name, "/") { - return "", fmt.Errorf("server name must be in format 'dns-namespace/name' (e.g., 'com.example.api/server')") - } - - parts := strings.SplitN(name, "/", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", fmt.Errorf("server name must be in format 'dns-namespace/name' with non-empty namespace and name parts") - } - - return name, nil -} - -// ValidateRemoteNamespaceMatch validates that remote URLs match the reverse-DNS namespace -func ValidateRemoteNamespaceMatch(serverDetail ServerDetail) error { - namespace := serverDetail.Name - - for _, remote := range serverDetail.Remotes { - if err := validateRemoteURLMatchesNamespace(remote.URL, namespace); err != nil { - return fmt.Errorf("remote URL %s does not match namespace %s: %w", remote.URL, namespace, err) - } - } - - return nil -} - -// validateRemoteURLMatchesNamespace checks if a remote URL's hostname matches the publisher domain from the namespace -func validateRemoteURLMatchesNamespace(remoteURL, namespace string) error { - // Parse the URL to extract the hostname - parsedURL, err := url.Parse(remoteURL) - if err != nil { - return fmt.Errorf("invalid URL format: %w", err) - } - - hostname := parsedURL.Hostname() - if hostname == "" { - return fmt.Errorf("URL must have a valid hostname") - } - - // Skip validation for localhost and local development URLs - if hostname == "localhost" || strings.HasSuffix(hostname, ".localhost") || hostname == "127.0.0.1" { - return nil - } - - // Extract publisher domain from reverse-DNS namespace - publisherDomain := extractPublisherDomainFromNamespace(namespace) - if publisherDomain == "" { - return fmt.Errorf("invalid namespace format: cannot extract domain from %s", namespace) - } - - // Check if the remote URL hostname matches the publisher domain or is a subdomain - if !isValidHostForDomain(hostname, publisherDomain) { - return fmt.Errorf("remote URL host %s does not match publisher domain %s", hostname, publisherDomain) - } - - return nil -} - -// extractPublisherDomainFromNamespace converts reverse-DNS namespace to normal domain format -// e.g., "com.example" -> "example.com" -func extractPublisherDomainFromNamespace(namespace string) string { - // Extract the namespace part before the first slash - namespacePart := namespace - if slashIdx := strings.Index(namespace, "/"); slashIdx != -1 { - namespacePart = namespace[:slashIdx] - } - - // Split into parts and reverse them to get normal domain format - parts := strings.Split(namespacePart, ".") - if len(parts) < 2 { - return "" - } - - // Reverse the parts to convert from reverse-DNS to normal domain - slices.Reverse(parts) - - return strings.Join(parts, ".") -} - -// isValidHostForDomain checks if a hostname is the domain or a subdomain of the publisher domain -func isValidHostForDomain(hostname, publisherDomain string) bool { - // Exact match - if hostname == publisherDomain { - return true - } - - // Subdomain match - hostname should end with "." + publisherDomain - if strings.HasSuffix(hostname, "."+publisherDomain) { - return true - } - - return false -} - -// ToServerResponse converts a ServerRecord to API response format -func (sr *ServerRecord) ToServerResponse() ServerResponse { - response := ServerResponse{ - Server: sr.ServerJSON, - } - - // Add registry metadata extension - response.XIOModelContextProtocolRegistry = sr.RegistryMetadata.CreateRegistryExtensions()["x-io.modelcontextprotocol.registry"] - - // Add publisher extensions directly - if len(sr.PublisherExtensions) > 0 { - response.XPublisher = sr.PublisherExtensions - } - - return response -} diff --git a/internal/model/model_test.go b/internal/model/model_test.go deleted file mode 100644 index d5ad3b7..0000000 --- a/internal/model/model_test.go +++ /dev/null @@ -1,468 +0,0 @@ -//nolint:testpackage -package model - -import ( - "encoding/json" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestExtractPublisherExtensions(t *testing.T) { - tests := []struct { - name string - request PublishRequest - expected map[string]interface{} - }{ - { - name: "nil publisher extensions", - request: PublishRequest{ - Server: ServerDetail{Name: "test"}, - }, - expected: map[string]interface{}{}, - }, - { - name: "empty publisher extensions", - request: PublishRequest{ - Server: ServerDetail{Name: "test"}, - XPublisher: map[string]interface{}{}, - }, - expected: map[string]interface{}{}, - }, - { - name: "simple publisher extensions", - request: PublishRequest{ - Server: ServerDetail{Name: "test"}, - XPublisher: map[string]interface{}{ - "build_info": map[string]interface{}{ - "version": "1.2.3", - "commit": "abc123", - }, - "publisher_metadata": "test-publisher", - }, - }, - expected: map[string]interface{}{ - "build_info": map[string]interface{}{ - "version": "1.2.3", - "commit": "abc123", - }, - "publisher_metadata": "test-publisher", - }, - }, - { - name: "nested publisher extensions", - request: PublishRequest{ - Server: ServerDetail{Name: "test"}, - XPublisher: map[string]interface{}{ - "ci": map[string]interface{}{ - "pipeline": map[string]interface{}{ - "id": "12345", - "branch": "main", - }, - "artifacts": []string{"binary", "docs"}, - }, - }, - }, - expected: map[string]interface{}{ - "ci": map[string]interface{}{ - "pipeline": map[string]interface{}{ - "id": "12345", - "branch": "main", - }, - "artifacts": []string{"binary", "docs"}, - }, - }, - }, - { - name: "non-map publisher extensions (should be ignored)", - request: PublishRequest{ - Server: ServerDetail{Name: "test"}, - XPublisher: "invalid-string-data", - }, - expected: map[string]interface{}{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ExtractPublisherExtensions(tt.request) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestExtractPublisherExtensions_DoesNotDoubleNest(t *testing.T) { - // This test specifically verifies the bug we fixed - no double nesting - request := PublishRequest{ - Server: ServerDetail{Name: "test"}, - XPublisher: map[string]interface{}{ - "tool": "publisher-cli", - "version": "1.0.0", - }, - } - - result := ExtractPublisherExtensions(request) - - // Verify we get the data directly, not wrapped in another "x-publisher" key - assert.Equal(t, "publisher-cli", result["tool"]) - assert.Equal(t, "1.0.0", result["version"]) - - // Verify we don't have double nesting - assert.NotContains(t, result, "x-publisher") -} - -func TestRegistryMetadata_CreateRegistryExtensions(t *testing.T) { - publishedTime := time.Date(2023, 12, 1, 10, 30, 0, 0, time.UTC) - updatedTime := time.Date(2023, 12, 1, 11, 0, 0, 0, time.UTC) - - metadata := RegistryMetadata{ - ID: "test-id-123", - PublishedAt: publishedTime, - UpdatedAt: updatedTime, - IsLatest: true, - ReleaseDate: "2023-12-01T10:30:00Z", - } - - result := metadata.CreateRegistryExtensions() - - expected := map[string]interface{}{ - "x-io.modelcontextprotocol.registry": map[string]interface{}{ - "id": "test-id-123", - "published_at": publishedTime, - "updated_at": updatedTime, - "is_latest": true, - "release_date": "2023-12-01T10:30:00Z", - }, - } - - assert.Equal(t, expected, result) -} - -func TestServerResponse_JSONSerialization(t *testing.T) { - // Test that ServerResponse properly serializes to extension wrapper format - publishedTime := time.Date(2023, 12, 1, 10, 30, 0, 0, time.UTC) - - response := ServerResponse{ - Server: ServerDetail{ - Name: "test-server", - Description: "A test server", - Repository: Repository{ - URL: "https://github.com/test/server", - Source: "github", - ID: "test/server", - }, - VersionDetail: VersionDetail{ - Version: "1.0.0", - }, - }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": "registry-id-123", - "published_at": publishedTime.Format(time.RFC3339), - "is_latest": true, - }, - XPublisher: map[string]interface{}{ - "build_tool": "ci-pipeline", - "commit": "abc123def", - }, - } - - // Serialize to JSON - jsonData, err := json.Marshal(response) - require.NoError(t, err) - - // Parse back to verify structure - var parsed map[string]interface{} - err = json.Unmarshal(jsonData, &parsed) - require.NoError(t, err) - - // Verify three-layer structure exists - assert.Contains(t, parsed, "server") - assert.Contains(t, parsed, "x-io.modelcontextprotocol.registry") - assert.Contains(t, parsed, "x-publisher") - - // Verify server data - serverData := parsed["server"].(map[string]interface{}) - assert.Equal(t, "test-server", serverData["name"]) - - // Verify registry metadata - registryData := parsed["x-io.modelcontextprotocol.registry"].(map[string]interface{}) - assert.Equal(t, "registry-id-123", registryData["id"]) - - // Verify publisher extensions - publisherData := parsed["x-publisher"].(map[string]interface{}) - assert.Equal(t, "ci-pipeline", publisherData["build_tool"]) - assert.Equal(t, "abc123def", publisherData["commit"]) -} - -func TestPublishRequest_WithPublisherExtensions(t *testing.T) { - // Test that PublishRequest properly deserializes publisher extensions - jsonData := `{ - "server": { - "name": "test-server", - "description": "Test server", - "version_detail": { - "version": "1.0.0" - } - }, - "x-publisher": { - "tool": "publisher-cli", - "metadata": { - "build_date": "2023-12-01", - "commit": "abc123" - } - } - }` - - var request PublishRequest - err := json.Unmarshal([]byte(jsonData), &request) - require.NoError(t, err) - - // Verify server data - assert.Equal(t, "test-server", request.Server.Name) - assert.Equal(t, "Test server", request.Server.Description) - - // Verify publisher extensions - require.NotNil(t, request.XPublisher) - publisherMap := request.XPublisher.(map[string]interface{}) - assert.Equal(t, "publisher-cli", publisherMap["tool"]) - - metadata := publisherMap["metadata"].(map[string]interface{}) - assert.Equal(t, "2023-12-01", metadata["build_date"]) - assert.Equal(t, "abc123", metadata["commit"]) -} - -func TestServerResponse_EmptyExtensions(t *testing.T) { - // Test behavior with empty/nil extensions - response := ServerResponse{ - Server: ServerDetail{ - Name: "minimal-server", - Description: "Minimal test", - }, - XIOModelContextProtocolRegistry: map[string]interface{}{ - "id": "min-id", - }, - XPublisher: nil, - } - - jsonData, err := json.Marshal(response) - require.NoError(t, err) - - var parsed map[string]interface{} - err = json.Unmarshal(jsonData, &parsed) - require.NoError(t, err) - - // Should still have the structure, even with nil publisher extensions - assert.Contains(t, parsed, "server") - assert.Contains(t, parsed, "x-io.modelcontextprotocol.registry") - - // x-publisher should be null/nil in JSON when empty - publisherValue, exists := parsed["x-publisher"] - if exists { - assert.Nil(t, publisherValue) - } -} - -func TestValidateRemoteNamespaceMatch(t *testing.T) { - tests := []struct { - name string - serverDetail ServerDetail - expectError bool - errorMsg string - }{ - { - name: "valid match - example.com domain", - serverDetail: ServerDetail{ - Name: "com.example/test-server", - Remotes: []Remote{ - {URL: "https://example.com/mcp"}, - }, - }, - expectError: false, - }, - { - name: "valid match - subdomain mcp.example.com", - serverDetail: ServerDetail{ - Name: "com.example/test-server", - Remotes: []Remote{ - {URL: "https://mcp.example.com/endpoint"}, - }, - }, - expectError: false, - }, - { - name: "valid match - api subdomain", - serverDetail: ServerDetail{ - Name: "com.example/api-server", - Remotes: []Remote{ - {URL: "https://api.example.com/mcp"}, - }, - }, - expectError: false, - }, - { - name: "invalid - wrong domain", - serverDetail: ServerDetail{ - Name: "com.example/test-server", - Remotes: []Remote{ - {URL: "https://google.com/mcp"}, - }, - }, - expectError: true, - errorMsg: "remote URL host google.com does not match publisher domain example.com", - }, - { - name: "invalid - different domain entirely", - serverDetail: ServerDetail{ - Name: "com.microsoft/server", - Remotes: []Remote{ - {URL: "https://api.github.com/endpoint"}, - }, - }, - expectError: true, - errorMsg: "remote URL host api.github.com does not match publisher domain microsoft.com", - }, - { - name: "localhost URLs allowed with any namespace", - serverDetail: ServerDetail{ - Name: "com.example/test-server", - Remotes: []Remote{ - {URL: "http://localhost:3000/sse"}, - }, - }, - expectError: false, - }, - { - name: "invalid URL format", - serverDetail: ServerDetail{ - Name: "com.example/test", - Remotes: []Remote{ - {URL: "not-a-valid-url"}, - }, - }, - expectError: true, - errorMsg: "URL must have a valid hostname", - }, - { - name: "empty remotes array", - serverDetail: ServerDetail{ - Name: "com.example/test", - Remotes: []Remote{}, - }, - expectError: false, - }, - { - name: "multiple valid remotes - different subdomains", - serverDetail: ServerDetail{ - Name: "com.example/server", - Remotes: []Remote{ - {URL: "https://api.example.com/sse"}, - {URL: "https://mcp.example.com/websocket"}, - }, - }, - expectError: false, - }, - { - name: "one valid, one invalid remote", - serverDetail: ServerDetail{ - Name: "com.example/server", - Remotes: []Remote{ - {URL: "https://example.com/sse"}, - {URL: "https://google.com/websocket"}, - }, - }, - expectError: true, - errorMsg: "remote URL host google.com does not match publisher domain example.com", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateRemoteNamespaceMatch(tt.serverDetail) - - if tt.expectError { - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.errorMsg) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestParseServerName(t *testing.T) { - tests := []struct { - name string - serverDetail ServerDetail - expectError bool - errorMsg string - }{ - { - name: "valid namespace/name format", - serverDetail: ServerDetail{ - Name: "com.example.api/server", - }, - expectError: false, - }, - { - name: "valid complex namespace", - serverDetail: ServerDetail{ - Name: "com.github.microsoft.azure/webapp-server", - }, - expectError: false, - }, - { - name: "empty server name", - serverDetail: ServerDetail{ - Name: "", - }, - expectError: true, - errorMsg: "server name is required", - }, - { - name: "missing slash separator", - serverDetail: ServerDetail{ - Name: "com.example.server", - }, - expectError: true, - errorMsg: "server name must be in format 'dns-namespace/name'", - }, - { - name: "empty namespace part", - serverDetail: ServerDetail{ - Name: "/server-name", - }, - expectError: true, - errorMsg: "non-empty namespace and name parts", - }, - { - name: "empty name part", - serverDetail: ServerDetail{ - Name: "com.example/", - }, - expectError: true, - errorMsg: "non-empty namespace and name parts", - }, - { - name: "multiple slashes - uses first as separator", - serverDetail: ServerDetail{ - Name: "com.example/server/path", - }, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := ParseServerName(tt.serverDetail) - - if tt.expectError { - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.errorMsg) - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/internal/service/fake_service.go b/internal/service/fake_service.go index 53906e6..4430bab 100644 --- a/internal/service/fake_service.go +++ b/internal/service/fake_service.go @@ -6,7 +6,9 @@ import ( "github.com/google/uuid" "github.com/modelcontextprotocol/registry/internal/database" - "github.com/modelcontextprotocol/registry/internal/model" + "github.com/modelcontextprotocol/registry/internal/validators" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" ) // fakeRegistryService implements RegistryService interface with an in-memory database @@ -16,8 +18,8 @@ type fakeRegistryService struct { // NewFakeRegistryService creates a new fake registry service with pre-populated data func NewFakeRegistryService() RegistryService { - // Sample registry entries with updated model structure using ServerDetail - serverDetails := []*model.ServerDetail{ + // Sample registry entries with updated model structure using ServerJSON + serverDetails := []*model.ServerJSON{ { Name: "bluegreen/mcp-server", Description: "A dummy MCP registry for testing", @@ -57,7 +59,7 @@ func NewFakeRegistryService() RegistryService { } // Create a new in-memory database using registry metadata IDs - serverDetailMap := make(map[string]*model.ServerDetail) + serverDetailMap := make(map[string]*model.ServerJSON) for _, entry := range serverDetails { registryID := uuid.New().String() // Generate registry metadata ID serverDetailMap[registryID] = entry @@ -69,7 +71,7 @@ func NewFakeRegistryService() RegistryService { } // List retrieves servers with extension wrapper format -func (s *fakeRegistryService) List(cursor string, limit int) ([]model.ServerResponse, string, error) { +func (s *fakeRegistryService) List(cursor string, limit int) ([]apiv1.ServerRecord, string, error) { // Create a timeout context for the database operation ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -80,17 +82,17 @@ func (s *fakeRegistryService) List(cursor string, limit int) ([]model.ServerResp return nil, "", err } - // Convert ServerRecord to ServerResponse format - result := make([]model.ServerResponse, len(serverRecords)) + // Return ServerRecords directly (they're now the same as ServerResponse) + result := make([]apiv1.ServerRecord, len(serverRecords)) for i, record := range serverRecords { - result[i] = record.ToServerResponse() + 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) (*model.ServerResponse, error) { +func (s *fakeRegistryService) GetByID(id string) (*apiv1.ServerRecord, error) { // Create a timeout context for the database operation ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -101,33 +103,32 @@ func (s *fakeRegistryService) GetByID(id string) (*model.ServerResponse, error) return nil, err } - // Convert ServerRecord to ServerResponse format - response := serverRecord.ToServerResponse() - return &response, nil + // 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 model.PublishRequest) (*model.ServerResponse, error) { +func (s *fakeRegistryService) Publish(req apiv1.PublishRequest) (*apiv1.ServerRecord, 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 := model.ValidatePublisherExtensions(req); err != nil { + if err := validators.ValidatePublisherExtensions(req); err != nil { return nil, err } // Validate server name exists - if _, err := model.ParseServerName(req.Server); err != nil { + if _, err := validators.ParseServerName(req.Server); err != nil { return nil, err } // Extract publisher extensions from request - publisherExtensions := model.ExtractPublisherExtensions(req) + publisherExtensions := validators.ExtractPublisherExtensions(req) // Create registry metadata for fake service (always marks as latest) now := time.Now() - registryMetadata := model.RegistryMetadata{ + registryMetadata := apiv1.RegistryExtensions{ ID: uuid.New().String(), PublishedAt: now, UpdatedAt: now, @@ -141,28 +142,27 @@ func (s *fakeRegistryService) Publish(req model.PublishRequest) (*model.ServerRe return nil, err } - // Convert ServerRecord to ServerResponse format - response := serverRecord.ToServerResponse() - return &response, nil + // 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 model.PublishRequest) (*model.ServerResponse, error) { +func (s *fakeRegistryService) EditServer(id string, req apiv1.PublishRequest) (*apiv1.ServerRecord, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Validate the request - if err := model.ValidatePublisherExtensions(req); err != nil { + if err := validators.ValidatePublisherExtensions(req); err != nil { return nil, err } // Validate server name exists and format - if _, err := model.ParseServerName(req.Server); err != nil { + if _, err := validators.ParseServerName(req.Server); err != nil { return nil, err } // Extract publisher extensions from request - publisherExtensions := model.ExtractPublisherExtensions(req) + publisherExtensions := validators.ExtractPublisherExtensions(req) // Update server in database serverRecord, err := s.db.UpdateServer(ctx, id, req.Server, publisherExtensions) @@ -170,9 +170,8 @@ func (s *fakeRegistryService) EditServer(id string, req model.PublishRequest) (* return nil, err } - // Convert ServerRecord to ServerResponse format - response := serverRecord.ToServerResponse() - return &response, nil + // Return ServerRecord directly (it's now the same as ServerResponse) + return serverRecord, nil } // Close closes the in-memory database connection diff --git a/internal/service/registry_service.go b/internal/service/registry_service.go index 7f99bbc..9689ac3 100644 --- a/internal/service/registry_service.go +++ b/internal/service/registry_service.go @@ -10,7 +10,9 @@ import ( "github.com/google/uuid" "github.com/modelcontextprotocol/registry/internal/database" - "github.com/modelcontextprotocol/registry/internal/model" + "github.com/modelcontextprotocol/registry/internal/validators" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" ) const maxServerVersionsPerServer = 10000 @@ -28,7 +30,7 @@ func NewRegistryServiceWithDB(db database.Database) RegistryService { } // List returns registry entries with cursor-based pagination in extension wrapper format -func (s *registryServiceImpl) List(cursor string, limit int) ([]model.ServerResponse, string, error) { +func (s *registryServiceImpl) List(cursor string, limit int) ([]apiv1.ServerRecord, string, error) { // Create a timeout context for the database operation ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -44,17 +46,17 @@ func (s *registryServiceImpl) List(cursor string, limit int) ([]model.ServerResp return nil, "", err } - // Convert ServerRecord to ServerResponse format - result := make([]model.ServerResponse, len(serverRecords)) + // Return ServerRecords directly (they're now the same as ServerResponse) + result := make([]apiv1.ServerRecord, len(serverRecords)) for i, record := range serverRecords { - result[i] = record.ToServerResponse() + result[i] = *record } return result, nextCursor, nil } // GetByID retrieves a specific server by its registry metadata ID in extension wrapper format -func (s *registryServiceImpl) GetByID(id string) (*model.ServerResponse, error) { +func (s *registryServiceImpl) GetByID(id string) (*apiv1.ServerRecord, error) { // Create a timeout context for the database operation ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -65,9 +67,8 @@ func (s *registryServiceImpl) GetByID(id string) (*model.ServerResponse, error) return nil, err } - // Convert ServerRecord to ServerResponse format - response := serverRecord.ToServerResponse() - return &response, nil + // Return ServerRecord directly (it's now the same as ServerResponse) + return serverRecord, nil } // validateMCPBPackage validates MCPB packages @@ -126,18 +127,18 @@ func validatePackage(pkg *model.Package) error { } // Publish publishes a server with separated extensions -func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerResponse, error) { +func (s *registryServiceImpl) Publish(req apiv1.PublishRequest) (*apiv1.ServerRecord, 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 := model.ValidatePublisherExtensions(req); err != nil { + if err := validators.ValidatePublisherExtensions(req); err != nil { return nil, err } // Validate server name exists and format - if _, err := model.ParseServerName(req.Server); err != nil { + if _, err := validators.ParseServerName(req.Server); err != nil { return nil, err } @@ -149,7 +150,7 @@ func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerRe } // Validate reverse-DNS namespace matching for remote URLs - if err := model.ValidateRemoteNamespaceMatch(req.Server); err != nil { + if err := validators.ValidateRemoteNamespaceMatch(req.Server); err != nil { return nil, err } @@ -179,16 +180,16 @@ func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerRe // Check all existing versions for duplicates and determine if new version should be latest for _, server := range existingServers { - existingVersion := server.ServerJSON.VersionDetail.Version + existingVersion := server.Server.VersionDetail.Version // Early exit: check for duplicate version if existingVersion == newVersion { return nil, database.ErrInvalidVersion } - if server.RegistryMetadata.IsLatest { - existingLatestID = server.RegistryMetadata.ID - existingTime, _ := time.Parse(time.RFC3339, server.RegistryMetadata.ReleaseDate) + if server.XIOModelContextProtocolRegistry.IsLatest { + existingLatestID = server.XIOModelContextProtocolRegistry.ID + existingTime, _ := time.Parse(time.RFC3339, server.XIOModelContextProtocolRegistry.ReleaseDate) // Compare versions using the proper versioning strategy comparison := CompareVersions(newVersion, existingVersion, currentTime, existingTime) @@ -200,10 +201,10 @@ func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerRe } // Extract publisher extensions from request - publisherExtensions := model.ExtractPublisherExtensions(req) + publisherExtensions := validators.ExtractPublisherExtensions(req) // Create registry metadata with service-determined values - registryMetadata := model.RegistryMetadata{ + registryMetadata := apiv1.RegistryExtensions{ ID: uuid.New().String(), PublishedAt: currentTime, UpdatedAt: currentTime, @@ -225,13 +226,12 @@ func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerRe return nil, err } - // Convert ServerRecord to ServerResponse format - response := serverRecord.ToServerResponse() - return &response, nil + // Return ServerRecord directly (it's now the same as ServerResponse) + return serverRecord, nil } // validateNoDuplicateRemoteURLs checks that no other server is using the same remote URLs -func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context, serverDetail model.ServerDetail) error { +func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context, serverDetail model.ServerJSON) error { // Check each remote URL in the new server for conflicts for _, remote := range serverDetail.Remotes { // Use filter to find servers with this remote URL @@ -246,8 +246,8 @@ func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context, // Check if any conflicting server has a different name for _, conflictingServer := range conflictingServers { - if conflictingServer.ServerJSON.Name != serverDetail.Name { - return fmt.Errorf("remote URL %s is already used by server %s", remote.URL, conflictingServer.ServerJSON.Name) + if conflictingServer.Server.Name != serverDetail.Name { + return fmt.Errorf("remote URL %s is already used by server %s", remote.URL, conflictingServer.Server.Name) } } } @@ -256,17 +256,17 @@ func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context, } // EditServer updates an existing server with new details (admin operation) -func (s *registryServiceImpl) EditServer(id string, req model.PublishRequest) (*model.ServerResponse, error) { +func (s *registryServiceImpl) EditServer(id string, req apiv1.PublishRequest) (*apiv1.ServerRecord, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Validate the request - if err := model.ValidatePublisherExtensions(req); err != nil { + if err := validators.ValidatePublisherExtensions(req); err != nil { return nil, err } // Validate server name exists and format - if _, err := model.ParseServerName(req.Server); err != nil { + if _, err := validators.ParseServerName(req.Server); err != nil { return nil, err } @@ -278,7 +278,7 @@ func (s *registryServiceImpl) EditServer(id string, req model.PublishRequest) (* } // Validate reverse-DNS namespace matching for remote URLs - if err := model.ValidateRemoteNamespaceMatch(req.Server); err != nil { + if err := validators.ValidateRemoteNamespaceMatch(req.Server); err != nil { return nil, err } @@ -288,7 +288,7 @@ func (s *registryServiceImpl) EditServer(id string, req model.PublishRequest) (* } // Extract publisher extensions from request - publisherExtensions := model.ExtractPublisherExtensions(req) + publisherExtensions := validators.ExtractPublisherExtensions(req) // Update server in database serverRecord, err := s.db.UpdateServer(ctx, id, req.Server, publisherExtensions) @@ -296,7 +296,6 @@ func (s *registryServiceImpl) EditServer(id string, req model.PublishRequest) (* return nil, err } - // Convert ServerRecord to ServerResponse format - response := serverRecord.ToServerResponse() - return &response, nil + // Return ServerRecord directly (it's now the same as ServerResponse) + return serverRecord, nil } diff --git a/internal/service/registry_service_test.go b/internal/service/registry_service_test.go index 84b33a4..9a8031d 100644 --- a/internal/service/registry_service_test.go +++ b/internal/service/registry_service_test.go @@ -6,13 +6,13 @@ import ( "testing" "github.com/modelcontextprotocol/registry/internal/database" - "github.com/modelcontextprotocol/registry/internal/model" + "github.com/modelcontextprotocol/registry/pkg/model" "github.com/stretchr/testify/assert" ) func TestValidateNoDuplicateRemoteURLs(t *testing.T) { // Create test data - existingServers := map[string]*model.ServerDetail{ + existingServers := map[string]*model.ServerJSON{ "existing1": { Name: "com.example/existing-server", Description: "An existing server", @@ -41,13 +41,13 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) { tests := []struct { name string - serverDetail model.ServerDetail + serverDetail model.ServerJSON expectError bool errorMsg string }{ { name: "no remote URLs - should pass", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "com.example/new-server", Description: "A new server with no remotes", VersionDetail: model.VersionDetail{ @@ -59,7 +59,7 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) { }, { name: "new unique remote URLs - should pass", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "com.example/new-server", Description: "A new server", VersionDetail: model.VersionDetail{ @@ -74,7 +74,7 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) { }, { name: "duplicate remote URL - should fail", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "com.example/new-server", Description: "A new server with duplicate URL", VersionDetail: model.VersionDetail{ @@ -89,7 +89,7 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) { }, { name: "updating same server with same URLs - should pass", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "com.example/existing-server", // Same name as existing Description: "Updated existing server", VersionDetail: model.VersionDetail{ @@ -107,9 +107,9 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() impl := service.(*registryServiceImpl) - + err := impl.validateNoDuplicateRemoteURLs(ctx, tt.serverDetail) - + if tt.expectError { assert.Error(t, err) assert.Contains(t, err.Error(), tt.errorMsg) @@ -118,4 +118,4 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/service/service.go b/internal/service/service.go index 6826506..70ad619 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -1,15 +1,15 @@ package service -import "github.com/modelcontextprotocol/registry/internal/model" +import apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" -// RegistryService defines the interface for registry operations with extension wrapper architecture +// RegistryService defines the interface for registry operations type RegistryService interface { - // List retrieves servers with extension wrapper format - List(cursor string, limit int) ([]model.ServerResponse, string, error) - // GetByID retrieves a single server by registry metadata ID with extension wrapper format - GetByID(id string) (*model.ServerResponse, error) + // List retrieves servers with unified format + List(cursor string, limit int) ([]apiv1.ServerRecord, string, error) + // GetByID retrieves a single server by registry metadata ID with unified format + GetByID(id string) (*apiv1.ServerRecord, error) // Publish publishes a server with separated extensions - Publish(req model.PublishRequest) (*model.ServerResponse, error) + Publish(req apiv1.PublishRequest) (*apiv1.ServerRecord, error) // EditServer updates an existing server with new details - EditServer(id string, req model.PublishRequest) (*model.ServerResponse, error) + EditServer(id string, req apiv1.PublishRequest) (*apiv1.ServerRecord, error) } diff --git a/internal/validators/validators.go b/internal/validators/validators.go index 85165a9..3ffc47d 100644 --- a/internal/validators/validators.go +++ b/internal/validators/validators.go @@ -1,9 +1,14 @@ package validators import ( + "encoding/json" "fmt" + "net/url" + "slices" + "strings" - "github.com/modelcontextprotocol/registry/internal/model" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" ) // ServerValidator validates server details @@ -12,7 +17,7 @@ type ServerValidator struct { } // Validate checks if the server details are valid -func (v *ServerValidator) Validate(obj *model.ServerDetail) error { +func (v *ServerValidator) Validate(obj *model.ServerJSON) error { if err := v.RepositoryValidator.Validate(&obj.Repository); err != nil { return err } @@ -104,7 +109,7 @@ func NewObjectValidator() *ObjectValidator { } } -func (ov *ObjectValidator) Validate(obj *model.ServerDetail) error { +func (ov *ObjectValidator) Validate(obj *model.ServerJSON) error { if err := ov.ServerValidator.Validate(obj); err != nil { return err } @@ -122,3 +127,163 @@ func (ov *ObjectValidator) Validate(obj *model.ServerDetail) error { } return nil } + +// ValidatePublisherExtensions validates that publisher extensions are within size limits +func ValidatePublisherExtensions(req apiv1.PublishRequest) error { + const maxExtensionSize = 4 * 1024 // 4KB limit + + // Check size limit for x-publisher extension + if req.XPublisher != nil { + extensionsJSON, err := json.Marshal(req.XPublisher) + if err != nil { + return fmt.Errorf("failed to marshal x-publisher extension: %w", err) + } + if len(extensionsJSON) > maxExtensionSize { + return fmt.Errorf("x-publisher extension exceeds 4KB limit (%d bytes)", len(extensionsJSON)) + } + } + + return nil +} + +// ValidatePublishRequestExtensions validates that only allowed extension fields are present +func ValidatePublishRequestExtensions(requestData []byte) error { + // Parse the raw JSON to check for unknown fields + var rawRequest map[string]interface{} + if err := json.Unmarshal(requestData, &rawRequest); err != nil { + return fmt.Errorf("failed to parse request JSON: %w", err) + } + + // Define allowed top-level fields + allowedFields := map[string]bool{ + "server": true, + "x-publisher": true, + } + + // Check for any disallowed fields + var invalidFields []string + for field := range rawRequest { + if !allowedFields[field] { + invalidFields = append(invalidFields, field) + } + } + + if len(invalidFields) > 0 { + return fmt.Errorf("invalid extension fields: %v. Only 'server' and 'x-publisher' fields are allowed", invalidFields) + } + + return nil +} + +// ExtractPublisherExtensions extracts publisher extensions from a apiv1.PublishRequest +func ExtractPublisherExtensions(req apiv1.PublishRequest) map[string]interface{} { + publisherExtensions := make(map[string]interface{}) + if req.XPublisher != nil { + // Copy fields directly, avoiding double nesting + for k, v := range req.XPublisher { + publisherExtensions[k] = v + } + } + return publisherExtensions +} + +// ParseServerName extracts the server name from a model.ServerJSON for validation purposes +func ParseServerName(serverDetail model.ServerJSON) (string, error) { + name := serverDetail.Name + if name == "" { + return "", fmt.Errorf("server name is required and must be a string") + } + + // Validate format: dns-namespace/name + if !strings.Contains(name, "/") { + return "", fmt.Errorf("server name must be in format 'dns-namespace/name' (e.g., 'com.example.api/server')") + } + + parts := strings.SplitN(name, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", fmt.Errorf("server name must be in format 'dns-namespace/name' with non-empty namespace and name parts") + } + + return name, nil +} + +// ValidateRemoteNamespaceMatch validates that remote URLs match the reverse-DNS namespace +func ValidateRemoteNamespaceMatch(serverDetail model.ServerJSON) error { + namespace := serverDetail.Name + + for _, remote := range serverDetail.Remotes { + if err := validateRemoteURLMatchesNamespace(remote.URL, namespace); err != nil { + return fmt.Errorf("remote URL %s does not match namespace %s: %w", remote.URL, namespace, err) + } + } + + return nil +} + +// validateRemoteURLMatchesNamespace checks if a remote URL's hostname matches the publisher domain from the namespace +func validateRemoteURLMatchesNamespace(remoteURL, namespace string) error { + // Parse the URL to extract the hostname + parsedURL, err := url.Parse(remoteURL) + if err != nil { + return fmt.Errorf("invalid URL format: %w", err) + } + + hostname := parsedURL.Hostname() + if hostname == "" { + return fmt.Errorf("URL must have a valid hostname") + } + + // Skip validation for localhost and local development URLs + if hostname == "localhost" || strings.HasSuffix(hostname, ".localhost") || hostname == "127.0.0.1" { + return nil + } + + // Extract publisher domain from reverse-DNS namespace + publisherDomain := extractPublisherDomainFromNamespace(namespace) + if publisherDomain == "" { + return fmt.Errorf("invalid namespace format: cannot extract domain from %s", namespace) + } + + // Check if the remote URL hostname matches the publisher domain or is a subdomain + if !isValidHostForDomain(hostname, publisherDomain) { + return fmt.Errorf("remote URL host %s does not match publisher domain %s", hostname, publisherDomain) + } + + return nil +} + +// extractPublisherDomainFromNamespace converts reverse-DNS namespace to normal domain format +// e.g., "com.example" -> "example.com" +func extractPublisherDomainFromNamespace(namespace string) string { + // Extract the namespace part before the first slash + namespacePart := namespace + if slashIdx := strings.Index(namespace, "/"); slashIdx != -1 { + namespacePart = namespace[:slashIdx] + } + + // Split into parts and reverse them to get normal domain format + parts := strings.Split(namespacePart, ".") + if len(parts) < 2 { + return "" + } + + // Reverse the parts to convert from reverse-DNS to normal domain + slices.Reverse(parts) + + return strings.Join(parts, ".") +} + +// isValidHostForDomain checks if a hostname is the domain or a subdomain of the publisher domain +func isValidHostForDomain(hostname, publisherDomain string) bool { + // Exact match + if hostname == publisherDomain { + return true + } + + // Subdomain match - hostname should end with "." + publisherDomain + if strings.HasSuffix(hostname, "."+publisherDomain) { + return true + } + + return false +} diff --git a/internal/validators/validators_test.go b/internal/validators/validators_test.go index d84d6bd..fa389cc 100644 --- a/internal/validators/validators_test.go +++ b/internal/validators/validators_test.go @@ -1,11 +1,15 @@ package validators_test import ( + "encoding/json" "testing" + "time" - "github.com/modelcontextprotocol/registry/internal/model" "github.com/modelcontextprotocol/registry/internal/validators" + apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1" + "github.com/modelcontextprotocol/registry/pkg/model" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestObjectValidator_Validate(t *testing.T) { @@ -13,12 +17,12 @@ func TestObjectValidator_Validate(t *testing.T) { tests := []struct { name string - serverDetail model.ServerDetail + serverDetail model.ServerJSON expectedError string }{ { name: "valid server detail with all fields", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "test-server", Description: "A test server", Repository: model.Repository{ @@ -46,7 +50,7 @@ func TestObjectValidator_Validate(t *testing.T) { }, { name: "server with invalid repository source", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "test-server", Description: "A test server", Repository: model.Repository{ @@ -61,7 +65,7 @@ func TestObjectValidator_Validate(t *testing.T) { }, { name: "server with invalid GitHub URL format", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "test-server", Description: "A test server", Repository: model.Repository{ @@ -76,7 +80,7 @@ func TestObjectValidator_Validate(t *testing.T) { }, { name: "server with invalid GitLab URL format", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "test-server", Description: "A test server", Repository: model.Repository{ @@ -91,7 +95,7 @@ func TestObjectValidator_Validate(t *testing.T) { }, { name: "package with spaces in name", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "test-server", Description: "A test server", Repository: model.Repository{ @@ -113,7 +117,7 @@ func TestObjectValidator_Validate(t *testing.T) { }, { name: "multiple packages with one invalid", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "test-server", Description: "A test server", Repository: model.Repository{ @@ -140,7 +144,7 @@ func TestObjectValidator_Validate(t *testing.T) { }, { name: "remote with invalid URL", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "test-server", Description: "A test server", Repository: model.Repository{ @@ -160,7 +164,7 @@ func TestObjectValidator_Validate(t *testing.T) { }, { name: "remote with missing scheme", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "test-server", Description: "A test server", Repository: model.Repository{ @@ -180,7 +184,7 @@ func TestObjectValidator_Validate(t *testing.T) { }, { name: "multiple remotes with one invalid", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "test-server", Description: "A test server", Repository: model.Repository{ @@ -203,7 +207,7 @@ func TestObjectValidator_Validate(t *testing.T) { }, { name: "server detail with nil packages and remotes", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "test-server", Description: "A test server", Repository: model.Repository{ @@ -220,7 +224,7 @@ func TestObjectValidator_Validate(t *testing.T) { }, { name: "server detail with empty packages and remotes slices", - serverDetail: model.ServerDetail{ + serverDetail: model.ServerJSON{ Name: "test-server", Description: "A test server", Repository: model.Repository{ @@ -250,3 +254,433 @@ func TestObjectValidator_Validate(t *testing.T) { }) } } + +func TestExtractPublisherExtensions(t *testing.T) { + tests := []struct { + name string + request apiv1.PublishRequest + expected map[string]interface{} + }{ + { + name: "nil publisher extensions", + request: apiv1.PublishRequest{ + Server: model.ServerJSON{Name: "test"}, + }, + expected: map[string]interface{}{}, + }, + { + name: "empty publisher extensions", + request: apiv1.PublishRequest{ + Server: model.ServerJSON{Name: "test"}, + XPublisher: map[string]interface{}{}, + }, + expected: map[string]interface{}{}, + }, + { + name: "simple publisher extensions", + request: apiv1.PublishRequest{ + Server: model.ServerJSON{Name: "test"}, + XPublisher: map[string]interface{}{ + "build_info": map[string]interface{}{ + "version": "1.2.3", + "commit": "abc123", + }, + "publisher_metadata": "test-publisher", + }, + }, + expected: map[string]interface{}{ + "build_info": map[string]interface{}{ + "version": "1.2.3", + "commit": "abc123", + }, + "publisher_metadata": "test-publisher", + }, + }, + { + name: "nested publisher extensions", + request: apiv1.PublishRequest{ + Server: model.ServerJSON{Name: "test"}, + XPublisher: map[string]interface{}{ + "ci": map[string]interface{}{ + "pipeline": map[string]interface{}{ + "id": "12345", + "branch": "main", + }, + "artifacts": []string{"binary", "docs"}, + }, + }, + }, + expected: map[string]interface{}{ + "ci": map[string]interface{}{ + "pipeline": map[string]interface{}{ + "id": "12345", + "branch": "main", + }, + "artifacts": []string{"binary", "docs"}, + }, + }, + }, + { + name: "nil publisher extensions (should be ignored)", + request: apiv1.PublishRequest{ + Server: model.ServerJSON{Name: "test"}, + XPublisher: nil, + }, + expected: map[string]interface{}{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validators.ExtractPublisherExtensions(tt.request) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractPublisherExtensions_DoesNotDoubleNest(t *testing.T) { + // This test specifically verifies the bug we fixed - no double nesting + request := apiv1.PublishRequest{ + Server: model.ServerJSON{Name: "test"}, + XPublisher: map[string]interface{}{ + "tool": "publisher-cli", + "version": "1.0.0", + }, + } + + result := validators.ExtractPublisherExtensions(request) + + // Verify we get the data directly, not wrapped in another "x-publisher" key + assert.Equal(t, "publisher-cli", result["tool"]) + assert.Equal(t, "1.0.0", result["version"]) + + // Verify we don't have double nesting + assert.NotContains(t, result, "x-publisher") +} + +func TestServerResponse_JSONSerialization(t *testing.T) { + // Test that ServerResponse properly serializes to extension wrapper format + publishedTime := time.Date(2023, 12, 1, 10, 30, 0, 0, time.UTC) + + response := apiv1.ServerRecord{ + Server: model.ServerJSON{ + Name: "test-server", + Description: "A test server", + Repository: model.Repository{ + URL: "https://github.com/test/server", + Source: "github", + ID: "test/server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + }, + }, + XIOModelContextProtocolRegistry: apiv1.RegistryExtensions{ + ID: "registry-id-123", + PublishedAt: publishedTime, + IsLatest: true, + ReleaseDate: publishedTime.Format(time.RFC3339), + }, + XPublisher: map[string]interface{}{ + "build_tool": "ci-pipeline", + "commit": "abc123def", + }, + } + + // Serialize to JSON + jsonData, err := json.Marshal(response) + require.NoError(t, err) + + // Parse back to verify structure + var parsed map[string]interface{} + err = json.Unmarshal(jsonData, &parsed) + require.NoError(t, err) + + // Verify three-layer structure exists + assert.Contains(t, parsed, "server") + assert.Contains(t, parsed, "x-io.modelcontextprotocol.registry") + assert.Contains(t, parsed, "x-publisher") + + // Verify server data + serverData := parsed["server"].(map[string]interface{}) + assert.Equal(t, "test-server", serverData["name"]) + + // Verify registry metadata + registryData := parsed["x-io.modelcontextprotocol.registry"].(map[string]interface{}) + assert.Equal(t, "registry-id-123", registryData["id"]) + + // Verify publisher extensions + publisherData := parsed["x-publisher"].(map[string]interface{}) + assert.Equal(t, "ci-pipeline", publisherData["build_tool"]) + assert.Equal(t, "abc123def", publisherData["commit"]) +} + +func TestPublishRequest_WithPublisherExtensions(t *testing.T) { + // Test that PublishRequest properly deserializes publisher extensions + jsonData := `{ + "server": { + "name": "test-server", + "description": "Test server", + "version_detail": { + "version": "1.0.0" + } + }, + "x-publisher": { + "tool": "publisher-cli", + "metadata": { + "build_date": "2023-12-01", + "commit": "abc123" + } + } + }` + + var request apiv1.PublishRequest + err := json.Unmarshal([]byte(jsonData), &request) + require.NoError(t, err) + + // Verify server data + assert.Equal(t, "test-server", request.Server.Name) + assert.Equal(t, "Test server", request.Server.Description) + + // Verify publisher extensions + require.NotNil(t, request.XPublisher) + assert.Equal(t, "publisher-cli", request.XPublisher["tool"]) + + metadata := request.XPublisher["metadata"].(map[string]interface{}) + assert.Equal(t, "2023-12-01", metadata["build_date"]) + assert.Equal(t, "abc123", metadata["commit"]) +} + +func TestServerResponse_EmptyExtensions(t *testing.T) { + // Test behavior with empty/nil extensions + response := apiv1.ServerRecord{ + Server: model.ServerJSON{ + Name: "minimal-server", + Description: "Minimal test", + }, + XIOModelContextProtocolRegistry: apiv1.RegistryExtensions{ + ID: "min-id", + }, + XPublisher: nil, + } + + jsonData, err := json.Marshal(response) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(jsonData, &parsed) + require.NoError(t, err) + + // Should still have the structure, even with nil publisher extensions + assert.Contains(t, parsed, "server") + assert.Contains(t, parsed, "x-io.modelcontextprotocol.registry") + + // x-publisher should be null/nil in JSON when empty + publisherValue, exists := parsed["x-publisher"] + if exists { + assert.Nil(t, publisherValue) + } +} + +func TestValidateRemoteNamespaceMatch(t *testing.T) { + tests := []struct { + name string + serverDetail model.ServerJSON + expectError bool + errorMsg string + }{ + { + name: "valid match - example.com domain", + serverDetail: model.ServerJSON{ + Name: "com.example/test-server", + Remotes: []model.Remote{ + {URL: "https://example.com/mcp"}, + }, + }, + expectError: false, + }, + { + name: "valid match - subdomain mcp.example.com", + serverDetail: model.ServerJSON{ + Name: "com.example/test-server", + Remotes: []model.Remote{ + {URL: "https://mcp.example.com/endpoint"}, + }, + }, + expectError: false, + }, + { + name: "valid match - api subdomain", + serverDetail: model.ServerJSON{ + Name: "com.example/api-server", + Remotes: []model.Remote{ + {URL: "https://api.example.com/mcp"}, + }, + }, + expectError: false, + }, + { + name: "invalid - wrong domain", + serverDetail: model.ServerJSON{ + Name: "com.example/test-server", + Remotes: []model.Remote{ + {URL: "https://google.com/mcp"}, + }, + }, + expectError: true, + errorMsg: "remote URL host google.com does not match publisher domain example.com", + }, + { + name: "invalid - different domain entirely", + serverDetail: model.ServerJSON{ + Name: "com.microsoft/server", + Remotes: []model.Remote{ + {URL: "https://api.github.com/endpoint"}, + }, + }, + expectError: true, + errorMsg: "remote URL host api.github.com does not match publisher domain microsoft.com", + }, + { + name: "localhost URLs allowed with any namespace", + serverDetail: model.ServerJSON{ + Name: "com.example/test-server", + Remotes: []model.Remote{ + {URL: "http://localhost:3000/sse"}, + }, + }, + expectError: false, + }, + { + name: "invalid URL format", + serverDetail: model.ServerJSON{ + Name: "com.example/test", + Remotes: []model.Remote{ + {URL: "not-a-valid-url"}, + }, + }, + expectError: true, + errorMsg: "URL must have a valid hostname", + }, + { + name: "empty remotes array", + serverDetail: model.ServerJSON{ + Name: "com.example/test", + Remotes: []model.Remote{}, + }, + expectError: false, + }, + { + name: "multiple valid remotes - different subdomains", + serverDetail: model.ServerJSON{ + Name: "com.example/server", + Remotes: []model.Remote{ + {URL: "https://api.example.com/sse"}, + {URL: "https://mcp.example.com/websocket"}, + }, + }, + expectError: false, + }, + { + name: "one valid, one invalid remote", + serverDetail: model.ServerJSON{ + Name: "com.example/server", + Remotes: []model.Remote{ + {URL: "https://example.com/sse"}, + {URL: "https://google.com/websocket"}, + }, + }, + expectError: true, + errorMsg: "remote URL host google.com does not match publisher domain example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validators.ValidateRemoteNamespaceMatch(tt.serverDetail) + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestParseServerName(t *testing.T) { + tests := []struct { + name string + serverDetail model.ServerJSON + expectError bool + errorMsg string + }{ + { + name: "valid namespace/name format", + serverDetail: model.ServerJSON{ + Name: "com.example.api/server", + }, + expectError: false, + }, + { + name: "valid complex namespace", + serverDetail: model.ServerJSON{ + Name: "com.github.microsoft.azure/webapp-server", + }, + expectError: false, + }, + { + name: "empty server name", + serverDetail: model.ServerJSON{ + Name: "", + }, + expectError: true, + errorMsg: "server name is required", + }, + { + name: "missing slash separator", + serverDetail: model.ServerJSON{ + Name: "com.example.server", + }, + expectError: true, + errorMsg: "server name must be in format 'dns-namespace/name'", + }, + { + name: "empty namespace part", + serverDetail: model.ServerJSON{ + Name: "/server-name", + }, + expectError: true, + errorMsg: "non-empty namespace and name parts", + }, + { + name: "empty name part", + serverDetail: model.ServerJSON{ + Name: "com.example/", + }, + expectError: true, + errorMsg: "non-empty namespace and name parts", + }, + { + name: "multiple slashes - uses first as separator", + serverDetail: model.ServerJSON{ + Name: "com.example/server/path", + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := validators.ParseServerName(tt.serverDetail) + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/api/v1/types.go b/pkg/api/v1/types.go new file mode 100644 index 0000000..7b00205 --- /dev/null +++ b/pkg/api/v1/types.go @@ -0,0 +1,42 @@ +package v1 + +import ( + "time" + + "github.com/modelcontextprotocol/registry/pkg/model" +) + +// RegistryExtensions represents registry-generated metadata +type RegistryExtensions struct { + ID string `json:"id" bson:"_id"` + PublishedAt time.Time `json:"published_at" bson:"published_at"` + UpdatedAt time.Time `json:"updated_at,omitempty" bson:"updated_at,omitempty"` + IsLatest bool `json:"is_latest" bson:"is_latest"` + ReleaseDate string `json:"release_date" bson:"release_date"` +} + +// ServerRecord represents the unified storage and API response model +type ServerRecord struct { + Server model.ServerJSON `json:"server" bson:"server"` // Pure MCP server.json + XIOModelContextProtocolRegistry RegistryExtensions `json:"x-io.modelcontextprotocol.registry,omitempty" bson:"registry_extensions"` // Registry-generated data + XPublisher map[string]interface{} `json:"x-publisher,omitempty" bson:"publisher_extensions"` // x-publisher extensions +} + +// ServerListResponse represents the paginated server list response +type ServerListResponse struct { + Servers []ServerRecord `json:"servers"` + Metadata *Metadata `json:"metadata,omitempty"` +} + +// PublishRequest represents the API request format for publishing servers +type PublishRequest struct { + Server model.ServerJSON `json:"server"` + XPublisher map[string]interface{} `json:"x-publisher,omitempty"` +} + +// Metadata represents pagination metadata +type Metadata struct { + NextCursor string `json:"next_cursor,omitempty"` + Count int `json:"count,omitempty"` + Total int `json:"total,omitempty"` +} diff --git a/pkg/model/types.go b/pkg/model/types.go new file mode 100644 index 0000000..5ad0c96 --- /dev/null +++ b/pkg/model/types.go @@ -0,0 +1,107 @@ +package model + +// Status represents the lifecycle status of a server +type Status string + +const ( + StatusActive Status = "active" + StatusDeprecated Status = "deprecated" + StatusDeleted Status = "deleted" +) + +// ServerJSON represents complete server information as defined in the MCP spec (pure, no registry metadata) +type ServerJSON struct { + Schema string `json:"$schema,omitempty" bson:"$schema,omitempty"` + Name string `json:"name" minLength:"1" maxLength:"200" bson:"name"` + Description string `json:"description" minLength:"1" maxLength:"100" bson:"description"` + Status Status `json:"status,omitempty" minLength:"1" bson:"status,omitempty"` + Repository Repository `json:"repository,omitempty" bson:"repository"` + VersionDetail VersionDetail `json:"version_detail" bson:"version_detail"` + Packages []Package `json:"packages,omitempty" bson:"packages,omitempty"` + Remotes []Remote `json:"remotes,omitempty" bson:"remotes,omitempty"` +} + +// Package represents a package configuration +type Package struct { + // RegistryType indicates how to download packages (e.g., "npm", "pypi", "docker-hub", "mcpb") + RegistryType string `json:"registry_type,omitempty" bson:"registry_type,omitempty"` + // RegistryBaseURL is the base URL of the package registry + RegistryBaseURL string `json:"registry_base_url,omitempty" bson:"registry_base_url,omitempty"` + // Identifier is the package identifier - either a package name (for registries) or URL (for direct downloads) + Identifier string `json:"identifier,omitempty" bson:"identifier,omitempty"` + Version string `json:"version,omitempty" bson:"version,omitempty"` + FileSHA256 string `json:"file_sha256,omitempty" bson:"file_sha256,omitempty"` + RunTimeHint string `json:"runtime_hint,omitempty" bson:"runtime_hint,omitempty"` + RuntimeArguments []Argument `json:"runtime_arguments,omitempty" bson:"runtime_arguments,omitempty"` + PackageArguments []Argument `json:"package_arguments,omitempty" bson:"package_arguments,omitempty"` + EnvironmentVariables []KeyValueInput `json:"environment_variables,omitempty" bson:"environment_variables,omitempty"` +} + +// Remote represents a remote connection endpoint +type Remote struct { + TransportType string `json:"transport_type" bson:"transport_type"` + URL string `json:"url" format:"uri" bson:"url"` + Headers []KeyValueInput `json:"headers,omitempty" bson:"headers,omitempty"` +} + +// Repository represents a source code repository as defined in the spec +type Repository struct { + URL string `json:"url" bson:"url"` + Source string `json:"source" bson:"source"` + ID string `json:"id,omitempty" bson:"id,omitempty"` +} + +// Format represents the input format type +type Format string + +const ( + FormatString Format = "string" + FormatNumber Format = "number" + FormatBoolean Format = "boolean" + FormatFilePath Format = "file_path" +) + +// Input represents a configuration input +type Input struct { + Description string `json:"description,omitempty" bson:"description,omitempty"` + IsRequired bool `json:"is_required,omitempty" bson:"is_required,omitempty"` + Format Format `json:"format,omitempty" bson:"format,omitempty"` + Value string `json:"value,omitempty" bson:"value,omitempty"` + IsSecret bool `json:"is_secret,omitempty" bson:"is_secret,omitempty"` + Default string `json:"default,omitempty" bson:"default,omitempty"` + Choices []string `json:"choices,omitempty" bson:"choices,omitempty"` +} + +// InputWithVariables represents an input that can contain variables +type InputWithVariables struct { + Input `json:",inline" bson:",inline"` + Variables map[string]Input `json:"variables,omitempty" bson:"variables,omitempty"` +} + +// KeyValueInput represents a named input with variables +type KeyValueInput struct { + InputWithVariables `json:",inline" bson:",inline"` + Name string `json:"name" bson:"name"` +} + +// ArgumentType represents the type of argument +type ArgumentType string + +const ( + ArgumentTypePositional ArgumentType = "positional" + ArgumentTypeNamed ArgumentType = "named" +) + +// Argument defines a type that can be either a PositionalArgument or a NamedArgument +type Argument struct { + InputWithVariables `json:",inline" bson:",inline"` + Type ArgumentType `json:"type" bson:"type"` + Name string `json:"name,omitempty" bson:"name,omitempty"` + IsRepeated bool `json:"is_repeated,omitempty" bson:"is_repeated,omitempty"` + ValueHint string `json:"value_hint,omitempty" bson:"value_hint,omitempty"` +} + +// VersionDetail represents the version details of a server (pure MCP spec, no registry metadata) +type VersionDetail struct { + Version string `json:"version" bson:"version"` +} diff --git a/tools/publisher/main.go b/tools/publisher/main.go index 8f92d09..4e28413 100644 --- a/tools/publisher/main.go +++ b/tools/publisher/main.go @@ -13,6 +13,7 @@ import ( "os" "strings" + "github.com/modelcontextprotocol/registry/pkg/model" "github.com/modelcontextprotocol/registry/tools/publisher/auth" ) @@ -27,50 +28,6 @@ const ( ) // Server structure types for JSON generation -type Repository struct { - URL string `json:"url"` - Source string `json:"source"` -} - -type VersionDetail struct { - Version string `json:"version"` -} - -type EnvironmentVariable struct { - Name string `json:"name"` - Description string `json:"description"` -} - -type RuntimeArgument struct { - Description string `json:"description"` - IsRequired bool `json:"is_required"` - Format string `json:"format"` - Value string `json:"value"` - Default string `json:"default"` - Type string `json:"type"` - ValueHint string `json:"value_hint"` -} - -type Package struct { - RegistryType string `json:"registry_type,omitempty"` - RegistryBaseURL string `json:"registry_base_url,omitempty"` - Identifier string `json:"identifier,omitempty"` - Version string `json:"version,omitempty"` - RuntimeHint string `json:"runtime_hint,omitempty"` - RuntimeArguments []RuntimeArgument `json:"runtime_arguments,omitempty"` - PackageArguments []RuntimeArgument `json:"package_arguments,omitempty"` - EnvironmentVariables []EnvironmentVariable `json:"environment_variables,omitempty"` -} - -type ServerJSON struct { - Schema string `json:"$schema"` - Name string `json:"name"` - Description string `json:"description"` - Status string `json:"status,omitempty"` - Repository Repository `json:"repository"` - VersionDetail VersionDetail `json:"version_detail"` - Packages []Package `json:"packages"` -} func main() { if len(os.Args) < 2 { @@ -410,29 +367,31 @@ func publishToRegistry(registryURL string, mcpData []byte, token string) error { } // parseEnvironmentVariables parses environment variable specifications -func parseEnvironmentVariables(envVars []string) []EnvironmentVariable { - var environmentVariables []EnvironmentVariable +func parseEnvironmentVariables(envVars []string) []model.KeyValueInput { + var environmentVariables []model.KeyValueInput for _, envVar := range envVars { parts := strings.SplitN(envVar, ":", 2) + description := fmt.Sprintf("Environment variable for %s", parts[0]) if len(parts) == 2 { - environmentVariables = append(environmentVariables, EnvironmentVariable{ - Name: parts[0], - Description: parts[1], - }) - } else { - // If no description provided, use a default - environmentVariables = append(environmentVariables, EnvironmentVariable{ - Name: parts[0], - Description: fmt.Sprintf("Environment variable for %s", parts[0]), - }) + description = parts[1] } + + environmentVariables = append(environmentVariables, model.KeyValueInput{ + Name: parts[0], + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: description, + Format: model.FormatString, + }, + }, + }) } return environmentVariables } // parsePackageArguments parses package argument specifications -func parsePackageArguments(packageArgs []string) []RuntimeArgument { - var packageArguments []RuntimeArgument +func parsePackageArguments(packageArgs []string) []model.Argument { + var packageArguments []model.Argument for i, pkgArg := range packageArgs { parts := strings.SplitN(pkgArg, ":", 2) value := parts[0] @@ -441,22 +400,26 @@ func parsePackageArguments(packageArgs []string) []RuntimeArgument { description = parts[1] } - packageArguments = append(packageArguments, RuntimeArgument{ - Description: description, - IsRequired: true, // Package arguments are typically required - Format: "string", - Value: value, - Default: value, - Type: "positional", - ValueHint: value, + packageArguments = append(packageArguments, model.Argument{ + Type: model.ArgumentTypePositional, + ValueHint: value, + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: description, + IsRequired: true, // Package arguments are typically required + Format: model.FormatString, + Value: value, + Default: value, + }, + }, }) } return packageArguments } // parseRuntimeArguments parses the execute command to create runtime arguments -func parseRuntimeArguments(execute string) []RuntimeArgument { - var runtimeArguments []RuntimeArgument +func parseRuntimeArguments(execute string) []model.Argument { + var runtimeArguments []model.Argument if execute == "" { return runtimeArguments } @@ -470,14 +433,18 @@ func parseRuntimeArguments(execute string) []RuntimeArgument { // Skip the first part (command) and add each argument as a runtime argument for i, arg := range parts[1:] { description := getArgumentDescription(arg, i, parts) - runtimeArguments = append(runtimeArguments, RuntimeArgument{ - Description: description, - IsRequired: false, - Format: "string", - Value: arg, - Default: arg, - Type: "positional", - ValueHint: arg, + runtimeArguments = append(runtimeArguments, model.Argument{ + Type: model.ArgumentTypePositional, + ValueHint: arg, + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: description, + IsRequired: false, + Format: model.FormatString, + Value: arg, + Default: arg, + }, + }, }) } @@ -507,7 +474,7 @@ func createServerStructure( name, description, version, repoURL, repoSource, registryName, packageName, packageVersion, runtimeHint, execute string, envVars []string, packageArgs []string, status string, -) ServerJSON { +) model.ServerJSON { // Parse environment variables environmentVariables := parseEnvironmentVariables(envVars) @@ -562,31 +529,42 @@ func createServerStructure( } // Create package with new structured fields - pkg := Package{ + pkg := model.Package{ RegistryType: registryTypeValue, RegistryBaseURL: registryBaseURLValue, Identifier: identifier, Version: packageVersion, - RuntimeHint: runtimeHint, + RunTimeHint: runtimeHint, RuntimeArguments: runtimeArguments, PackageArguments: packageArguments, EnvironmentVariables: environmentVariables, } + // Convert string status to model.Status type + var statusValue model.Status + switch status { + case "active": + statusValue = model.StatusActive + case "deprecated": + statusValue = model.StatusDeprecated + default: + statusValue = model.StatusActive // fallback to active + } + // Create server structure - return ServerJSON{ + return model.ServerJSON{ Schema: "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", Name: name, Description: description, - Status: status, - Repository: Repository{ + Status: statusValue, + Repository: model.Repository{ URL: repoURL, Source: repoSource, }, - VersionDetail: VersionDetail{ + VersionDetail: model.VersionDetail{ Version: version, }, - Packages: []Package{pkg}, + Packages: []model.Package{pkg}, } }