registry/internal/api/handlers/v0/validate_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

173 lines
6.2 KiB
Go

package v0_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type issueStruct struct {
Type string `json:"type"`
Path string `json:"path"`
Message string `json:"message"`
Severity string `json:"severity"`
Reference string `json:"reference"`
}
func TestValidateEndpoint(t *testing.T) {
// 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.RegisterValidateEndpoint(api, "/v0")
testCases := []struct {
name string
serverJSON apiv0.ServerJSON
expectedValid bool
expectedStatus int
description string
validateIssues func(t *testing.T, issues []issueStruct)
}{
{
name: "valid server json",
serverJSON: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
},
expectedValid: true,
expectedStatus: http.StatusOK,
description: "Should return valid for a properly formatted server JSON",
validateIssues: func(t *testing.T, issues []issueStruct) {
t.Helper()
assert.Empty(t, issues, "Valid server JSON should have no issues")
},
},
{
name: "version range should be invalid",
serverJSON: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "^1.0.0", // Version range, not allowed
},
expectedValid: false,
expectedStatus: http.StatusOK,
description: "Should return invalid when version is a range instead of specific version",
validateIssues: func(t *testing.T, issues []issueStruct) {
t.Helper()
require.Greater(t, len(issues), 0, "Should have at least one issue")
issue := issues[0]
assert.Equal(t, "semantic", issue.Type, "Issue type should be semantic")
assert.Equal(t, "version", issue.Path, "Issue path should be 'version'")
assert.Equal(t, "error", issue.Severity, "Issue severity should be error")
assert.NotEmpty(t, issue.Message, "Issue message should not be empty")
assert.Contains(t, issue.Message, "^1.0.0", "Issue message should contain the version range")
assert.NotEmpty(t, issue.Reference, "Issue reference should not be empty")
},
},
{
name: "latest version should be invalid",
serverJSON: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "latest", // "latest" is reserved
},
expectedValid: false,
expectedStatus: http.StatusOK,
description: "Should return invalid when version is 'latest' (reserved word)",
validateIssues: func(t *testing.T, issues []issueStruct) {
t.Helper()
require.Greater(t, len(issues), 0, "Should have at least one issue")
issue := issues[0]
assert.Equal(t, "semantic", issue.Type, "Issue type should be semantic")
assert.Equal(t, "version", issue.Path, "Issue path should be 'version'")
assert.Equal(t, "error", issue.Severity, "Issue severity should be error")
assert.NotEmpty(t, issue.Message, "Issue message should not be empty")
assert.Contains(t, strings.ToLower(issue.Message), "latest", "Issue message should mention 'latest'")
assert.NotEmpty(t, issue.Reference, "Issue reference should not be empty")
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create request body
bodyBytes, err := json.Marshal(tc.serverJSON)
require.NoError(t, err)
// Create request
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/v0/validate", bytes.NewBuffer(bodyBytes))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// Perform request
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
// Assert status code
assert.Equal(t, tc.expectedStatus, rr.Code, "%s: expected status %d, got %d", tc.description, tc.expectedStatus, rr.Code)
// Parse response - Huma returns ValidationResult directly
var result struct {
Valid bool `json:"valid"`
Issues []issueStruct `json:"issues"`
}
err = json.Unmarshal(rr.Body.Bytes(), &result)
require.NoError(t, err, "Failed to parse response: %s", rr.Body.String())
// Debug output for failed tests
if tc.expectedValid != result.Valid {
t.Logf("Response body: %s", rr.Body.String())
t.Logf("Validation issues: %+v", result.Issues)
}
// Always log response for first test case to show structure
if tc.name == "valid server json" {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, rr.Body.Bytes(), "", " "); err == nil {
t.Logf("Example valid response:\n%s", prettyJSON.String())
}
}
if tc.name == "version range should be invalid" {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, rr.Body.Bytes(), "", " "); err == nil {
t.Logf("Example invalid response:\n%s", prettyJSON.String())
}
}
// Assert validity
assert.Equal(t, tc.expectedValid, result.Valid, "%s: expected valid=%v, got %v. Issues: %+v", tc.description, tc.expectedValid, result.Valid, result.Issues)
// Validate issues structure and content
if tc.validateIssues != nil {
tc.validateIssues(t, result.Issues)
} else if !tc.expectedValid {
// Default validation: if expected invalid, verify there are issues
assert.Greater(t, len(result.Issues), 0, "%s: expected validation issues but got none. Response: %s", tc.description, rr.Body.String())
// Validate all issues have required fields
for i, issue := range result.Issues {
assert.NotEmpty(t, issue.Type, "Issue %d: type should not be empty", i)
assert.NotEmpty(t, issue.Severity, "Issue %d: severity should not be empty", i)
assert.NotEmpty(t, issue.Message, "Issue %d: message should not be empty", i)
}
}
})
}
}