registry/cmd/publisher/commands/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

182 lines
4.7 KiB
Go

package commands_test
import (
"encoding/json"
"io"
"net/http"
"os"
"testing"
"github.com/modelcontextprotocol/registry/cmd/publisher/commands"
"github.com/modelcontextprotocol/registry/internal/validators"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateCommand_Success(t *testing.T) {
validateCallCount := 0
server := SetupMockRegistryServer(t,
nil, // No publish handler
func(w http.ResponseWriter, _ *http.Request) {
validateCallCount++
w.Header().Set("Content-Type", "application/json")
result := validators.ValidationResult{
Valid: true,
Issues: []validators.ValidationIssue{},
}
_ = json.NewEncoder(w).Encode(result)
},
)
SetupTestToken(t, server.URL, "test-token")
serverJSON := apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
}
CreateTestServerJSON(t, serverJSON)
err := commands.ValidateCommand([]string{})
assert.NoError(t, err)
assert.Equal(t, 1, validateCallCount, "validate endpoint should be called")
}
func TestValidateCommand_WithErrors(t *testing.T) {
validateCallCount := 0
server := SetupMockRegistryServer(t,
nil,
func(w http.ResponseWriter, _ *http.Request) {
validateCallCount++
w.Header().Set("Content-Type", "application/json")
result := validators.ValidationResult{
Valid: false,
Issues: []validators.ValidationIssue{
{
Type: validators.ValidationIssueTypeSemantic,
Path: "version",
Message: "version must be a specific version, not a range",
Severity: validators.ValidationIssueSeverityError,
Reference: "semantic-version-range",
},
},
}
_ = json.NewEncoder(w).Encode(result)
},
)
SetupTestToken(t, server.URL, "test-token")
serverJSON := apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "^1.0.0", // Invalid
}
CreateTestServerJSON(t, serverJSON)
err := commands.ValidateCommand([]string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "validation failed")
assert.Equal(t, 1, validateCallCount, "validate endpoint should be called")
}
func TestValidateCommand_DeprecatedSchema(t *testing.T) {
server := SetupMockRegistryServer(t,
nil,
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body, _ := io.ReadAll(r.Body)
var req apiv0.ServerJSON
_ = json.Unmarshal(body, &req)
result := validators.ValidationResult{
Valid: false,
Issues: []validators.ValidationIssue{
{
Type: validators.ValidationIssueTypeSemantic,
Path: "schema",
Message: "schema version 2025-07-09 is not the current version",
Severity: validators.ValidationIssueSeverityWarning,
Reference: "schema-version-deprecated",
},
},
}
_ = json.NewEncoder(w).Encode(result)
},
)
SetupTestToken(t, server.URL, "test-token")
serverJSON := apiv0.ServerJSON{
Schema: "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
}
CreateTestServerJSON(t, serverJSON)
err := commands.ValidateCommand([]string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "schema version 2025-07-09")
assert.Contains(t, err.Error(), "Migration checklist:")
}
func TestValidateCommand_NoServerFile(t *testing.T) {
server := SetupMockRegistryServer(t, nil, nil)
SetupTestToken(t, server.URL, "test-token")
// Don't create server.json
tempDir, err := os.MkdirTemp("", "mcp-publisher-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalDir, err := os.Getwd()
require.NoError(t, err)
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(tempDir)
err = commands.ValidateCommand([]string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestValidateCommand_InvalidJSON(t *testing.T) {
server := SetupMockRegistryServer(t, nil, nil)
SetupTestToken(t, server.URL, "test-token")
tempDir, err := os.MkdirTemp("", "mcp-publisher-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalDir, err := os.Getwd()
require.NoError(t, err)
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(tempDir)
// Create invalid JSON file
err = os.WriteFile("server.json", []byte("{ invalid json }"), 0600)
require.NoError(t, err)
err = commands.ValidateCommand([]string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON")
}