registry/internal/api/handlers/v0/publish_test.go
Bob Dickinson f06d1eda8a
Added /validate endpoint and modified mcp-publish so that all validation happens there (#896)
## Motivation and Context

Per discussion with @rdimitrov - all validation now happens in a new
`/validate` endpoint. For details, see below.

## Breaking Changes

None. Validation on /publish only validates schema version and semantic
checks (manual checks) as before. We can add schema validation by
changing a flag in the called to `ValidateJSONSchema` when we're ready.

## Types of changes
- [X] New feature (non-breaking change which adds functionality)

## Checklist
- [X] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [X] My code follows the repository's style guidelines
- [X] New and existing tests pass locally
- [X] I have added appropriate error handling
- [X] I have added or updated documentation as needed

## Additional context

## API Changes

### 1. New `/validate` Endpoint

A new public endpoint was added for validating `server.json` files
without publishing them.

**Details:**
- **Paths:** `/v0/validate` and `/v0.1/validate`
- **Method:** `POST`
- **Authentication:** None required (public endpoint)
- **Request:** JSON body containing `ServerJSON`
- **Response:** `200 OK` with `ValidationResult` containing:
  - `valid` (boolean)
- `issues` (array of validation issues with type, path, message,
severity, reference)

**Implementation:**
- New handler file: `internal/api/handlers/v0/validate.go`
- Performs comprehensive validation using `ValidationAll` option:
  - Schema version validation
  - Full schema validation
  - Semantic validation
- Always returns `200 OK` - validity is indicated in `result.Valid`
field
- Registered in router for both `/v0` and `/v0.1` prefixes

### 2. `/publish` Endpoint Validation Flow Changes

The publish endpoint now validates earlier in the request flow and
provides clearer error responses.

**Before:** Validation was mixed into `ValidatePublishRequest()` (which
also handled publisher extensions and registry ownership).

**After:**
1. Validates `ServerJSON` structure first using `ValidateServerJSON()`
(schema version + semantic validation)
2. Returns `422 Unprocessable Entity` with message: "Failed to publish
server, invalid schema: call /validate for details"
3. Only proceeds to publisher extensions/registry validation if schema
validation passes
4. No longer calls `ValidatePublishRequest()` internally for schema
validation

**Key Changes:**
- Validation happens before `CreateServer()` call
- Returns `422` on schema/semantic validation failures
- Error message directs users to `/validate` endpoint for details
- `ValidatePublishRequest()` no longer performs schema validation (added
note in comments)

### 3. `/edit` Endpoint Validation Flow Changes

Similar validation changes were applied to the edit endpoint.

**Changes:**
- Added `ValidateServerJSON()` call before server update
- Returns `422` with same error message format on validation failure
- Matches the `/publish` endpoint behavior

### 4. Validator Functions Refactoring

Updated validator functions to reflect that schema validation is now
done separately.

**Changes to `ValidatePublishRequest()`:**
- Removed internal `ValidateServerJSON()` call
- Added note: "ValidateServerJSON should be called separately before
this function"
- Now only validates publisher extensions and registry ownership

**Changes to `ValidateUpdateRequest()`:**
- Same change (removed schema validation, added note)
- Now only validates registry ownership

## CLI Changes

### 1. `validate` Command: Now Uses API Endpoint

**Before:** Performed local validation using
`validators.ValidateServerJSON()`.

**After:** Calls the `/v0/validate` endpoint on the registry.

**Details:**
- Reads `server.json` and sends it to the registry API
- Displays validation results returned by the server
- Uses the same error formatting as before
- Registry URL comes from token file (or default)
- No authentication required (public endpoint)

**Implementation:**
- Added `validateViaAPI()` function that POSTs to `/v0/validate`
- Parses `ValidationResult` response from server
- Uses shared `printValidationIssues()` for formatting

### 2. `publish` Command: Validation Only on 422 Errors

**Before:** Performed upfront local schema validation before publishing.

**After:** Attempts publish first, then validates only if server returns
422.

**Details:**
- Attempts publish immediately (no upfront validation)
- If publish returns 422 (Unprocessable Entity):
  - Calls `/v0/validate` endpoint to get detailed validation errors
  - Formats and displays them using same logic as `validate` command
  - Returns error with migration links
- For non-422 errors (401, 403, etc.), returns original error (no
validation call)

**Implementation Changes:**
- Removed upfront `runValidationAndPrintIssues()` call
- Modified `publishToRegistry()` to return HTTP status code alongside
response/error
- Added 422 detection logic that calls `validateViaAPI()`

### 3. Shared Validation Error Formatting

**Before:** `runValidationAndPrintIssues()` function handled both
validation and printing together.

**After:** Refactored to separate printing logic.

**Details:**
- Created `printValidationIssues()` function - just prints validation
issues (no validation logic)
- Removed `runValidationAndPrintIssues()` function (no longer used)
- Both `validate` and `publish` commands use `printValidationIssues()`
for consistent output

**Behavior:**
- Prints schema validation errors with migration guidance
- Prints all other validation issues with formatting
- Returns formatted error message string for schema issues

## Summary

Both CLI commands now use the `/validate` API endpoint for validation
instead of local validation. The `publish` command validates only after
receiving a 422 error response, and both commands share the same error
formatting via `printValidationIssues()`.

### Benefits

- **Centralized validation logic** - All validation happens on the
server
- **Always up-to-date** - CLI always uses current server validation
rules
- **Consistent experience** - Same validation behavior whether using CLI
or API directly
- **Better separation of concerns** - Schema validation separated from
publisher-specific validation
2026-02-04 15:52:07 +02:00

544 lines
17 KiB
Go

package v0_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
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/database"
"github.com/modelcontextprotocol/registry/internal/service"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Helper function to generate a valid JWT token for testing
func generateTestJWTToken(cfg *config.Config, claims auth.JWTClaims) (string, error) {
jwtManager := auth.NewJWTManager(cfg)
ctx := context.Background()
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
if err != nil {
return "", err
}
return tokenResponse.RegistryToken, nil
}
func TestPublishEndpoint(t *testing.T) {
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
testConfig := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
EnableRegistryValidation: false, // Disable for unit tests
}
testCases := []struct {
name string
requestBody interface{}
tokenClaims *auth.JWTClaims
authHeader string
setupRegistryService func(service.RegistryService)
expectedStatus int
expectedError string
}{
{
name: "successful publish with GitHub auth",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "io.github.example/test-server",
Description: "A test server",
Repository: &model.Repository{
URL: "https://github.com/example/test-server",
Source: "github",
ID: "example/test-server",
},
Version: "1.0.0",
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodGitHubAT,
AuthMethodSubject: "example",
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
},
},
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
expectedStatus: http.StatusOK,
},
{
name: "successful publish with no auth (AuthMethodNone)",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "example/test-server",
Description: "A test server without auth",
Repository: &model.Repository{
URL: "https://github.com/example/test-server",
Source: "github",
ID: "example/test-server",
},
Version: "1.0.0",
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "example/*"},
},
},
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
expectedStatus: http.StatusOK,
},
{
name: "missing authorization header",
requestBody: apiv0.ServerJSON{},
authHeader: "", // Empty auth header
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "required header parameter is missing",
},
{
name: "invalid authorization header format",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "io.github.domdomegg/test-server",
Description: "Test server",
Version: "1.0.0",
},
authHeader: "InvalidFormat",
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
expectedStatus: http.StatusUnauthorized,
expectedError: "Invalid Authorization header format",
},
{
name: "invalid token",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "example/test-server",
Description: "A test server",
Version: "1.0.0",
},
authHeader: "Bearer invalidToken",
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
expectedStatus: http.StatusUnauthorized,
expectedError: "Invalid or expired Registry JWT token",
},
{
name: "permission denied",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "io.github.other/test-server",
Description: "A test server",
Version: "1.0.0",
Repository: &model.Repository{
URL: "https://github.com/example/test-server",
Source: "github",
ID: "example/test-server",
},
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodGitHubAT,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
},
},
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
expectedStatus: http.StatusForbidden,
expectedError: "You do not have permission to publish this server",
},
{
name: "registry service error",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "example/test-server",
Description: "A test server",
Version: "1.0.0",
Repository: &model.Repository{
URL: "https://github.com/example/test-server",
Source: "github",
ID: "example/test-server",
},
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
},
setupRegistryService: func(registry service.RegistryService) {
// Pre-publish the same server to cause duplicate version error
existingServer := apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "example/test-server",
Description: "Existing test server",
Version: "1.0.0",
Repository: &model.Repository{
URL: "https://github.com/example/test-server-existing",
Source: "github",
ID: "example/test-server-existing",
},
}
_, _ = registry.CreateServer(context.Background(), &existingServer)
},
expectedStatus: http.StatusBadRequest,
expectedError: "invalid version: cannot publish duplicate version",
},
{
name: "package validation success - MCPB package",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server-mcpb",
Description: "A test server with MCPB package",
Version: "1.0.0",
Packages: []model.Package{
{
RegistryType: model.RegistryTypeMCPB,
Identifier: "https://github.com/example/server/releases/download/v1.0.0/server.tar.gz",
Version: "1.0.0",
FileSHA256: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce",
Transport: model.Transport{
Type: model.TransportTypeStdio,
},
},
},
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
},
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusOK,
},
{
name: "invalid server name - multiple slashes (two slashes)",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/server/path",
Description: "Server with multiple slashes in name",
Version: "1.0.0",
Repository: &model.Repository{
URL: "https://github.com/example/test-server",
Source: "github",
ID: "example/test-server",
},
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
},
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "expected string to match pattern",
},
{
name: "invalid server name - multiple slashes (three slashes)",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "org.company/dept/team/project",
Description: "Server with three slashes in name",
Version: "1.0.0",
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
},
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "expected string to match pattern",
},
{
name: "invalid server name - consecutive slashes",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example//double-slash",
Description: "Server with consecutive slashes",
Version: "1.0.0",
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
},
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "expected string to match pattern",
},
{
name: "invalid server name - URL-like path",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/servers/v1/api",
Description: "Server with URL-like path structure",
Version: "1.0.0",
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
},
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "expected string to match pattern",
},
{
name: "invalid server name - many slashes",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "a/b/c/d/e/f",
Description: "Server with many slashes",
Version: "1.0.0",
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
},
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "expected string to match pattern",
},
{
name: "invalid server name - with packages and remotes",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test/server/v2",
Description: "Complex server with invalid name",
Version: "2.0.0",
Repository: &model.Repository{
URL: "https://github.com/example/test-server",
Source: "github",
ID: "example/test-server",
},
Packages: []model.Package{
{
RegistryType: model.RegistryTypeNPM,
Identifier: "test-package",
Version: "2.0.0",
Transport: model.Transport{
Type: model.TransportTypeStdio,
},
},
},
Remotes: []model.Transport{
{
Type: model.TransportTypeStreamableHTTP,
URL: "https://example.com/api",
},
},
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
},
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "expected string to match pattern",
},
{
name: "invalid schema - version range instead of specific version",
requestBody: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "^1.0.0", // Version range, not allowed
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
},
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "Failed to publish server, invalid schema: call /validate for details",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create registry service
registryService := service.NewRegistryService(database.NewTestDB(t), testConfig)
// Setup registry service
tc.setupRegistryService(registryService)
// Create a new ServeMux and Huma API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the endpoint with test config
v0.RegisterPublishEndpoint(api, "/v0", registryService, testConfig)
// Prepare request body
var requestBody []byte
if tc.requestBody != nil {
var err error
requestBody, err = json.Marshal(tc.requestBody)
assert.NoError(t, err)
}
// Create request
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/v0/publish", bytes.NewBuffer(requestBody))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// Set auth header
if tc.authHeader != "" {
req.Header.Set("Authorization", tc.authHeader)
} else if tc.tokenClaims != nil {
// Generate a valid JWT token
token, err := generateTestJWTToken(testConfig, *tc.tokenClaims)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
}
// Perform request
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
// Assertions
assert.Equal(t, tc.expectedStatus, rr.Code, "status code mismatch")
if tc.expectedError != "" {
assert.Contains(t, rr.Body.String(), tc.expectedError)
}
// No mock expectations to verify
})
}
}
// TestPublishEndpoint_MultipleSlashesEdgeCases tests additional edge cases for multi-slash validation
func TestPublishEndpoint_MultipleSlashesEdgeCases(t *testing.T) {
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
testConfig := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
EnableRegistryValidation: false,
}
testCases := []struct {
name string
serverName string
expectedStatus int
description string
}{
{
name: "valid - single slash",
serverName: "com.example/server",
expectedStatus: http.StatusOK,
description: "Valid server name with single slash should succeed",
},
{
name: "invalid - trailing slash after valid name",
serverName: "com.example/server/",
expectedStatus: http.StatusUnprocessableEntity,
description: "Trailing slash creates multiple slashes",
},
{
name: "invalid - leading and middle slash",
serverName: "/com.example/server",
expectedStatus: http.StatusUnprocessableEntity,
description: "Leading slash with middle slash",
},
{
name: "invalid - file system style path",
serverName: "usr/local/bin/server",
expectedStatus: http.StatusUnprocessableEntity,
description: "File system style paths should be rejected",
},
{
name: "invalid - version-like suffix",
serverName: "com.example/server/v1.0.0",
expectedStatus: http.StatusUnprocessableEntity,
description: "Version suffixes with slash should be rejected",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create registry service
registryService := service.NewRegistryService(database.NewTestDB(t), testConfig)
// Create a new ServeMux and Huma API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the endpoint
v0.RegisterPublishEndpoint(api, "/v0", registryService, testConfig)
// Create request body
requestBody := apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: tc.serverName,
Description: "Test server",
Version: "1.0.0",
}
bodyBytes, err := json.Marshal(requestBody)
require.NoError(t, err)
// Create request
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/v0/publish", bytes.NewBuffer(bodyBytes))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// Set auth header with permissions
tokenClaims := auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
}
token, err := generateTestJWTToken(testConfig, tokenClaims)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
// Perform request
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
// Assertions
assert.Equal(t, tc.expectedStatus, rr.Code,
"%s: expected status %d, got %d", tc.description, tc.expectedStatus, rr.Code)
if tc.expectedStatus == http.StatusUnprocessableEntity {
assert.Contains(t, rr.Body.String(), "expected string to match pattern",
"%s: should contain pattern validation error", tc.description)
}
})
}
}