From 56a23b8343062212634bdf50fb6ff339016ffe8b Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 5 Oct 2025 00:06:18 -0700 Subject: [PATCH 01/18] First pass at enhanced validation --- ENHANCED_VALIDATION_DESIGN.md | 479 ++++++++++++++++++ cmd/publisher/commands/validate.go | 68 +++ cmd/publisher/main.go | 3 + internal/validators/schema.go | 174 +++++++ .../validators/validation_detailed_test.go | 179 +++++++ internal/validators/validation_types.go | 98 ++++ internal/validators/validation_types_test.go | 173 +++++++ internal/validators/validators.go | 391 ++++++++++---- 8 files changed, 1461 insertions(+), 104 deletions(-) create mode 100644 ENHANCED_VALIDATION_DESIGN.md create mode 100644 cmd/publisher/commands/validate.go create mode 100644 internal/validators/schema.go create mode 100644 internal/validators/validation_detailed_test.go create mode 100644 internal/validators/validation_types.go create mode 100644 internal/validators/validation_types_test.go diff --git a/ENHANCED_VALIDATION_DESIGN.md b/ENHANCED_VALIDATION_DESIGN.md new file mode 100644 index 000000000..9692ece32 --- /dev/null +++ b/ENHANCED_VALIDATION_DESIGN.md @@ -0,0 +1,479 @@ +# Enhanced Server Validation Design + +## Overview + +This document outlines the design for enhancing the MCP Registry validation system to support comprehensive error collection with JSON path tracking. + +## Current State + +### Problems with Current Validation +- **Fail-fast behavior**: `ValidateServerJSON()` stops at first error +- **Limited feedback**: Users see only one error at a time +- **No path information**: Errors don't specify where in JSON the problem occurs +- **Manual error fixing**: Users must fix errors one by one + +### Current Architecture +```go +func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { + if err := validateRepository(&serverJSON.Repository); err != nil { + return err // ❌ Stops here + } + if err := validateVersion(serverJSON.Version); err != nil { + return err // ❌ Never reached if repository validation fails + } + // ... more validations +} +``` + +## Proposed Design + +### Design Goals + +1. **Comprehensive Feedback**: Collect all validation issues in a single pass, not just the first error +2. **Precise Location**: Provide exact JSON paths for every validation issue +3. **Structured Output**: Return machine-readable validation results with consistent format +4. **Backward Compatibility**: Maintain existing `ValidateServerJSON() error` signature +5. **Extensible**: Support different validation types (json, schema, semantic, linter) and severity levels + +### Core Types + +```go +// Validation issue type with constrained values +type ValidationIssueType string + +const ( + ValidationIssueTypeJSON ValidationIssueType = "json" + ValidationIssueTypeSchema ValidationIssueType = "schema" + ValidationIssueTypeSemantic ValidationIssueType = "semantic" + ValidationIssueTypeLinter ValidationIssueType = "linter" +) + +// Validation issue severity with constrained values +type ValidationIssueSeverity string + +const ( + ValidationIssueSeverityError ValidationIssueSeverity = "error" + ValidationIssueSeverityWarning ValidationIssueSeverity = "warning" + ValidationIssueSeverityInfo ValidationIssueSeverity = "info" +) + +type ValidationIssue struct { + Type ValidationIssueType `json:"type"` + Path string `json:"path"` // JSON path like "packages[0].transport.url" + Message string `json:"message"` // Error description (extracted from error.Error()) + Severity ValidationIssueSeverity `json:"severity"` + Rule string `json:"rule"` // Rule name like "prefer-transport-configuration" +} + +type ValidationResult struct { + Valid bool `json:"valid"` + Issues []ValidationIssue `json:"issues"` +} + +type ValidationContext struct { + path string +} + +// Constructor functions following Go conventions +func NewValidationIssue(issueType ValidationIssueType, path, message string, severity ValidationIssueSeverity, rule string) ValidationIssue +func NewValidationIssueFromError(issueType ValidationIssueType, path string, err error, rule string) ValidationIssue +``` + +### Validation Types + +The `Type` field categorizes validation issues by their source: + +- **`ValidationIssueTypeJSON`**: JSON parsing errors (malformed JSON syntax) +- **`ValidationIssueTypeSchema`**: JSON Schema validation errors (structural/format violations) +- **`ValidationIssueTypeSemantic`**: Logical validation errors not enforceable in schema (business rules) +- **`ValidationIssueTypeLinter`**: Best practice recommendations, security concerns, style guidelines + +The `Severity` field indicates the impact level: + +- **`ValidationIssueSeverityError`**: Critical issues that must be fixed +- **`ValidationIssueSeverityWarning`**: Issues that should be addressed +- **`ValidationIssueSeverityInfo`**: Suggestions and recommendations + +### Context Helper Methods + +```go +func (ctx *ValidationContext) Field(name string) *ValidationContext +func (ctx *ValidationContext) Index(i int) *ValidationContext +func (ctx *ValidationContext) String() string +``` + +### Backward Compatibility Strategy + +The design maintains perfect backward compatibility by leveraging Go's existing error handling patterns: + +#### **Error Message Preservation** +- **Current validators** use `fmt.Errorf("%w: %s", ErrInvalidRepositoryURL, obj.URL)` +- **New validators** use `NewValidationIssueFromError()` which extracts `err.Error()` +- **Result**: Identical error messages, ensuring all existing tests pass + +#### **Constructor Pattern** +Following Go conventions used throughout the project: +```go +// Standard constructor for manual field setting (linter rules, etc.) +issue := NewValidationIssue( + ValidationIssueTypeLinter, + "name", + "consider using descriptive server name", + ValidationIssueSeverityWarning, + "descriptive-naming", +) + +// Constructor that preserves existing error formatting (all current validators) +issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, // All existing validation uses "semantic" type + "repository.url", + fmt.Errorf("%w: %s", ErrInvalidRepositoryURL, obj.URL), // Same error creation as before + "invalid-repository-url", +) +``` + +#### **Error Interface Compatibility** +- Existing `ValidateServerJSON() error` signature unchanged +- Returns `fmt.Errorf("%s", issue.Message)` - same string format +- All `errors.Is()` and `errors.As()` calls continue to work +- No changes needed to error handling code + +### New Validation Architecture + +#### 1. All Validators Return ValidationResult + +```go +// Every validator becomes exhaustive and returns ValidationResult +func validateRepository(ctx *ValidationContext, obj *model.Repository) *ValidationResult +func validatePackageField(ctx *ValidationContext, obj *model.Package) *ValidationResult +func validateRemoteTransport(ctx *ValidationContext, obj *model.Transport) *ValidationResult +func validateVersion(ctx *ValidationContext, version string) *ValidationResult +func validateWebsiteURL(ctx *ValidationContext, websiteURL string) *ValidationResult +func validateArgument(ctx *ValidationContext, obj *model.Argument) *ValidationResult +``` + +#### 2. All Validators Use Context + +```go +func ValidateServerJSONDetailed(serverJSON *apiv0.ServerJSON) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + + // Validate server name - using existing error logic + if _, err := parseServerName(*serverJSON); err != nil { + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, // All existing validation uses "semantic" type + "name", + err, // Preserves existing error formatting + "invalid-server-name", + ) + result.AddIssue(issue) + } + + // Validate repository with context + if repoResult := validateRepository(&ValidationContext{}, &serverJSON.Repository); !repoResult.Valid { + result.Merge(repoResult) + } + + // Validate packages with array context + for i, pkg := range serverJSON.Packages { + pkgCtx := &ValidationContext{}.Field("packages").Index(i) + if pkgResult := validatePackageField(pkgCtx, &pkg); !pkgResult.Valid { + result.Merge(pkgResult) + } + } + + // Validate remotes with array context + for i, remote := range serverJSON.Remotes { + remoteCtx := &ValidationContext{}.Field("remotes").Index(i) + if remoteResult := validateRemoteTransport(remoteCtx, &remote); !remoteResult.Valid { + result.Merge(remoteResult) + } + } + + return result +} +``` + +#### 3. Existing Validator Becomes Simple Wrapper + +```go +func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { + result := ValidateServerJSONDetailed(serverJSON) + if !result.Valid { + // Return the first error-level issue + for _, issue := range result.Issues { + if issue.Severity == "error" { + return fmt.Errorf("%s: %s", issue.Path, issue.Message) + } + } + } + return nil +} +``` + +## Server Schema Validation + +The project already uses `github.com/santhosh-tekuri/jsonschema/v5` for schema validation. We can leverage this library to add comprehensive JSON Schema validation that produces detailed error information. + +### Schema Validation Integration + +```go +func validateServerJSONSchema(ctx *ValidationContext, serverJSON *apiv0.ServerJSON) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + + // Load server.schema.json + schema, err := loadServerSchema() + if err != nil { + // Handle schema loading error + return result + } + + // Validate against schema + if err := schema.Validate(serverJSON); err != nil { + // Convert jsonschema.ValidationError to ValidationIssue + if validationErr, ok := err.(*jsonschema.ValidationError); ok { + issue := ValidationIssue{ + Type: ValidationIssueTypeSchema, + Path: validationErr.Field, // JSON path from library + Message: validationErr.Description, // Detailed error message + Severity: ValidationIssueSeverityError, + Rule: "schema-validation", + } + result.AddIssue(issue) + } + } + + return result +} + +func ValidateServerJSONDetailed(serverJSON *apiv0.ServerJSON, validateSchema bool) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + + // Existing validation (always runs) + // ... existing validation logic ... + + // Optional schema validation + if validateSchema { + if schemaResult := validateServerJSONSchema(&ValidationContext{}, serverJSON); !schemaResult.Valid { + result.Merge(schemaResult) + } + } + + return result +} +``` + +### Benefits of Schema Validation + +#### **Comprehensive Coverage** +- **JSON Schema validation** catches structural issues not covered by Go validators +- **Detailed error messages** with exact JSON paths from the schema library +- **Standards compliance** ensures server.json follows the official schema + +#### **Rich Error Information** +The `jsonschema.ValidationError` provides: +- **Field**: Exact JSON path (e.g., `"packages[0].transport.url"`) +- **Description**: Detailed error message from schema +- **Type**: Error type (e.g., `"required"`, `"format"`, `"type"`) + +#### **Integration with Existing Library** +- **No new dependencies**: Uses existing `jsonschema/v5` library +- **Consistent with project**: Same library used in `tools/validate-examples/` +- **Proven reliability**: Already tested and used in the project + +## Implementation Plan + +### Phase 1: Add Core Types +- [ ] Create `ValidationIssue`, `ValidationResult`, `ValidationContext` types +- [ ] Add helper methods for context building and result merging +- [ ] Add unit tests for new types + +### Phase 2: Migrate Individual Validators +- [ ] Update `validateRepository()` to use context and return `*ValidationResult` + - Use `NewValidationIssueFromError()` to preserve existing error formatting + - Maintain same error messages for backward compatibility +- [ ] Update `validatePackageField()` to use context and return `*ValidationResult` +- [ ] Update `validateRemoteTransport()` to use context and return `*ValidationResult` +- [ ] Update `validateVersion()` to use context and return `*ValidationResult` +- [ ] Update `validateWebsiteURL()` to use context and return `*ValidationResult` +- [ ] Update all other individual validators +- [ ] Verify all existing tests continue to pass + +### Phase 3: Implement Main Validators +- [ ] Create `ValidateServerJSONDetailed()` function +- [ ] Update `ValidateServerJSON()` to be a simple wrapper +- [ ] Add comprehensive tests for path building + +### Phase 4: Add Server Schema Validation +- [ ] Add optional server schema validation using existing `jsonschema` library +- [ ] Convert schema validation errors to `ValidationIssue` format +- [ ] Add schema validation to `ValidateServerJSONDetailed()` with optional parameter + +### Phase 5: Update Commands +- [ ] Update `mcp-publisher validate` command to use detailed validation +- [ ] Add JSON output format option +- [ ] Add filtering options (errors only, warnings, etc.) + +### Phase 6: Testing and Documentation +- [ ] Add comprehensive test coverage +- [ ] Update documentation +- [ ] Performance testing +- [ ] Backward compatibility verification + +## Example Usage + +### JSON Output Format +```json +{ + "valid": false, + "issues": [ + { + "type": "json", + "path": "", + "message": "invalid JSON syntax at line 5, column 12", + "severity": "error", + "rule": "json-syntax-error" + }, + { + "type": "semantic", + "path": "name", + "message": "server name must be in format 'dns-namespace/name'", + "severity": "error", + "rule": "invalid-server-name" + }, + { + "type": "semantic", + "path": "packages[0].transport.url", + "message": "url is required for streamable-http transport type", + "severity": "error", + "rule": "missing-transport-url" + }, + { + "type": "schema", + "path": "packages[1].environmentVariables[0].name", + "message": "string does not match required pattern", + "severity": "error", + "rule": "schema-validation" + }, + { + "type": "linter", + "path": "packages[1].description", + "message": "consider adding a more descriptive package description", + "severity": "warning", + "rule": "descriptive-package-description" + } + ] +} +``` + +**Note**: The JSON output still uses string values for `type` and `severity` fields for JSON serialization compatibility, but the Go code uses the typed constants for type safety. + +### CLI Usage +```bash +# Basic validation +mcp-publisher validate server.json + +# JSON output format +mcp-publisher validate --format json server.json + +# Filter by severity +mcp-publisher validate --severity error server.json + +# Include schema validation +mcp-publisher validate --schema server.json +``` + +## Benefits + +### ✅ Comprehensive Feedback +- See all validation issues at once +- No need to fix errors one by one +- Better developer experience + +### ✅ Precise Error Location +- JSON paths show exactly where issues occur +- Easy to locate problems in large JSON files +- Structured error format with rule names + +### ✅ Structured Output +- JSON format for tooling integration +- Machine-readable error information +- Easy to parse and process programmatically + +### ✅ Backward Compatibility +- Existing `ValidateServerJSON() error` signature unchanged +- All existing code continues to work +- Leverages Go's error interface and existing error constants +- Constructor pattern follows established project conventions + +### ✅ Extensible Architecture +- Easy to add new validation types (schema, linter, warning) +- Easy to add new severity levels +- Easy to add filtering and formatting options + +## Technical Considerations + +### Go-Specific Design Rationale + +#### **Error Interface Compatibility** +- **Leverages existing error constants**: `ErrInvalidRepositoryURL`, `ErrVersionLooksLikeRange`, etc. +- **Preserves error wrapping**: Uses `fmt.Errorf("%w: %s", err, context)` pattern +- **Maintains error.Is() compatibility**: Existing error checking continues to work +- **No breaking changes**: All error handling code remains functional + +#### **Constructor Pattern** +Following established Go conventions in the project: +- **`NewValidationIssue()`**: Standard constructor following `NewXxx()` pattern +- **`NewValidationIssueFromError()`**: Specialized constructor for error conversion +- **Consistent with project**: Matches patterns used in `NewConfig()`, `NewServer()`, etc. +- **Type safety**: Compile-time validation of required fields + +#### **Context Passing Architecture** +- **Immutable context building**: `ctx.Field("name").Index(0)` pattern +- **Clean composition**: Validators focus on validation, not path building +- **Reusable validators**: Same validator can be called with different contexts +- **No global state**: Thread-safe validation with explicit context + +#### **Type Safety with Constrained Values** +Following Go best practices used throughout the project: +- **Typed string constants**: `ValidationIssueType`, `ValidationIssueSeverity` prevent invalid values +- **Compile-time validation**: IDE autocomplete and error checking +- **JSON compatibility**: Still serializes as strings for API compatibility +- **Refactoring safety**: Rename constants without breaking code +- **Consistent with project**: Matches patterns used in `Status`, `Format`, `ArgumentType` + +### Performance +- Slightly slower than fail-fast validation +- Memory usage increases with error collection +- Acceptable trade-off for better user experience + +### Testing Strategy +- Unit tests for each validator with context +- Integration tests for path building +- Backward compatibility tests +- Performance benchmarks + +### Migration Strategy +- Implement alongside existing validators +- Gradual migration of individual validators +- Thorough testing at each phase +- Rollback plan if issues arise + +## Future Enhancements + +### Additional Validation Types +- **Linter rules**: Best practices and style guidelines +- **Warning level**: Non-critical issues +- **Info level**: Suggestions and improvements + +### Advanced Features +- **Error filtering**: By type, severity, path pattern +- **Output formatting**: Human-readable, JSON, XML +- **Configuration**: Custom validation rules +- **IDE integration**: Real-time validation feedback + +### Tooling Integration +- **WASM package**: Browser-based validation +- **VS Code extension**: Real-time validation +- **CI/CD integration**: Automated validation in pipelines +- **API endpoint**: Validation as a service diff --git a/cmd/publisher/commands/validate.go b/cmd/publisher/commands/validate.go new file mode 100644 index 000000000..a77d2e203 --- /dev/null +++ b/cmd/publisher/commands/validate.go @@ -0,0 +1,68 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/modelcontextprotocol/registry/internal/validators" + apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" +) + +func ValidateCommand(args []string) error { + // Parse arguments + serverFile := "server.json" + + for _, arg := range args { + if !strings.HasPrefix(arg, "-") { + serverFile = arg + } + } + + // Read server.json + serverData, err := os.ReadFile(serverFile) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("server.json not found. Run 'mcp-publisher init' to create one") + } + return fmt.Errorf("failed to read server.json: %w", err) + } + + // Validate JSON + var serverJSON apiv0.ServerJSON + if err := json.Unmarshal(serverData, &serverJSON); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + + // Check for deprecated schema and recommend migration + // Allow empty schema (will use default) but reject old schemas + if serverJSON.Schema != "" && !strings.Contains(serverJSON.Schema, "2025-09-29") { + return fmt.Errorf(`deprecated schema detected: %s. + +Migrate to the current schema format for new servers. + +📋 Migration checklist: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md#migration-checklist-for-publishers +📖 Full changelog with examples: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md`, serverJSON.Schema) + } + + // Run detailed validation (this is the whole point of the validate command) + // Include schema validation for comprehensive validation + result := validators.ValidateServerJSONDetailed(&serverJSON, true) + + if result.Valid { + fmt.Println("✅ server.json is valid") + return nil + } + + // Print all issues + fmt.Printf("❌ Validation failed with %d issue(s):\n\n", len(result.Issues)) + for i, issue := range result.Issues { + fmt.Printf("%d. [%s] %s (%s)\n", i+1, issue.Severity, issue.Path, issue.Type) + fmt.Printf(" %s\n", issue.Message) + fmt.Printf(" Rule: %s\n", issue.Rule) + fmt.Println() + } + + return fmt.Errorf("validation failed") +} diff --git a/cmd/publisher/main.go b/cmd/publisher/main.go index db0ef924c..6467263a2 100644 --- a/cmd/publisher/main.go +++ b/cmd/publisher/main.go @@ -37,6 +37,8 @@ func main() { err = commands.LogoutCommand() case "publish": err = commands.PublishCommand(os.Args[2:]) + case "validate": + err = commands.ValidateCommand(os.Args[2:]) case "--version", "-v", "version": log.Printf("mcp-publisher %s (commit: %s, built: %s)", Version, GitCommit, BuildTime) return @@ -65,6 +67,7 @@ func printUsage() { _, _ = fmt.Fprintln(os.Stdout, " login Authenticate with the registry") _, _ = fmt.Fprintln(os.Stdout, " logout Clear saved authentication") _, _ = fmt.Fprintln(os.Stdout, " publish Publish server.json to the registry") + _, _ = fmt.Fprintln(os.Stdout, " validate Validate server.json without publishing") _, _ = fmt.Fprintln(os.Stdout) _, _ = fmt.Fprintln(os.Stdout, "Use 'mcp-publisher --help' for more information about a command.") } diff --git a/internal/validators/schema.go b/internal/validators/schema.go new file mode 100644 index 000000000..b626b39b2 --- /dev/null +++ b/internal/validators/schema.go @@ -0,0 +1,174 @@ +package validators + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "strings" + + apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/santhosh-tekuri/jsonschema/v5" +) + +// validateServerJSONSchema validates the server JSON against server.schema.json using jsonschema +func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + + // Load the schema file - find it relative to the binary's location + schemaPath := "docs/reference/server-json/server.schema.json" + + // If running from bin/ directory, go up one level to find the schema + if _, err := os.Stat(schemaPath); os.IsNotExist(err) { + schemaPath = "../docs/reference/server-json/server.schema.json" + } + + // Try to find the schema file relative to the current working directory + schemaData, err := os.ReadFile(schemaPath) + if err != nil { + // If we can't load the schema, return an error - schema validation is required + issue := NewValidationIssue( + ValidationIssueTypeSchema, + "", + fmt.Sprintf("failed to load schema file '%s': %v", schemaPath, err), + ValidationIssueSeverityError, + "schema-load-error", + ) + result.AddIssue(issue) + return result + } + + // Parse the schema + var schema map[string]interface{} + if err := json.Unmarshal(schemaData, &schema); err != nil { + // If we can't parse the schema, return an error + issue := NewValidationIssue( + ValidationIssueTypeSchema, + "", + fmt.Sprintf("failed to parse schema file: %v", err), + ValidationIssueSeverityError, + "schema-parse-error", + ) + result.AddIssue(issue) + return result + } + + // Convert the server JSON to a map for validation + serverData, err := json.Marshal(serverJSON) + if err != nil { + issue := NewValidationIssue( + ValidationIssueTypeJSON, + "", + fmt.Sprintf("failed to marshal server JSON for schema validation: %v", err), + ValidationIssueSeverityError, + "json-marshal-error", + ) + result.AddIssue(issue) + return result + } + + var serverMap map[string]interface{} + if err := json.Unmarshal(serverData, &serverMap); err != nil { + issue := NewValidationIssue( + ValidationIssueTypeJSON, + "", + fmt.Sprintf("failed to unmarshal server JSON for schema validation: %v", err), + ValidationIssueSeverityError, + "json-unmarshal-error", + ) + result.AddIssue(issue) + return result + } + + // Validate against schema using jsonschema library + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("file:///server.schema.json", bytes.NewReader(schemaData)); err != nil { + // If we can't add the schema resource, return an error + issue := NewValidationIssue( + ValidationIssueTypeSchema, + "", + fmt.Sprintf("failed to add schema resource: %v", err), + ValidationIssueSeverityError, + "schema-resource-error", + ) + result.AddIssue(issue) + return result + } + + schemaInstance, err := compiler.Compile("file:///server.schema.json") + if err != nil { + // If we can't compile the schema, return an error + issue := NewValidationIssue( + ValidationIssueTypeSchema, + "", + fmt.Sprintf("failed to compile schema: %v", err), + ValidationIssueSeverityError, + "schema-compile-error", + ) + result.AddIssue(issue) + return result + } + + // Perform validation + if err := schemaInstance.Validate(serverMap); err != nil { + // Convert validation error to our issue format + if validationErr, ok := err.(*jsonschema.ValidationError); ok { + // Process the validation error and its causes + addValidationError(result, validationErr) + } else { + // Fallback for other error types + issue := NewValidationIssue( + ValidationIssueTypeSchema, + "", + fmt.Sprintf("schema validation failed: %v", err), + ValidationIssueSeverityError, + "schema-validation-error", + ) + result.AddIssue(issue) + } + } + + return result +} + +// addValidationError processes validation errors and extracts useful information +func addValidationError(result *ValidationResult, validationErr *jsonschema.ValidationError) { + // Use DetailedOutput to get the nested error details + detailed := validationErr.DetailedOutput() + addDetailedErrors(result, detailed) +} + +// addDetailedErrors recursively processes detailed validation errors +func addDetailedErrors(result *ValidationResult, detailed jsonschema.Detailed) { + // Only process errors that have specific field paths and meaningful messages + if detailed.InstanceLocation != "" && detailed.Error != "" { + // Convert JSON Pointer to readable path (remove leading slash, convert / to .) + path := strings.TrimPrefix(detailed.InstanceLocation, "/") + path = strings.ReplaceAll(path, "/", ".") + + // Clean up the error message + message := detailed.Error + + // Make messages more user-friendly + if strings.Contains(message, "missing properties:") { + message = strings.ReplaceAll(message, "missing properties:", "missing required fields:") + } + if strings.Contains(message, "is not valid") { + message = strings.ReplaceAll(message, "is not valid", "has invalid format") + } + + issue := NewValidationIssue( + ValidationIssueTypeSchema, + path, + message, + ValidationIssueSeverityError, + "schema-validation", + ) + result.AddIssue(issue) + } + + // Process nested errors + for _, nested := range detailed.Errors { + addDetailedErrors(result, nested) + } +} diff --git a/internal/validators/validation_detailed_test.go b/internal/validators/validation_detailed_test.go new file mode 100644 index 000000000..6fdf4b07f --- /dev/null +++ b/internal/validators/validation_detailed_test.go @@ -0,0 +1,179 @@ +package validators_test + +import ( + "testing" + + "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" +) + +func TestValidateServerJSONDetailed_CollectsAllErrors(t *testing.T) { + // Create a server JSON with multiple validation errors + serverJSON := &apiv0.ServerJSON{ + Name: "invalid-name", // Invalid server name format + Version: "^1.0.0", // Invalid version range + Description: "Test server", + Repository: model.Repository{ + URL: "not-a-valid-url", // Invalid repository URL + Source: "github", + }, + WebsiteURL: "ftp://invalid-scheme.com", // Invalid website URL scheme + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeOCI, + RegistryBaseURL: "https://docker.io", + Identifier: "package with spaces", // Invalid package name + Version: "latest", // Reserved version + Transport: model.Transport{ + Type: model.TransportTypeStdio, + URL: "should-not-have-url", // Invalid stdio transport with URL + }, + RuntimeArguments: []model.Argument{ + { + Type: model.ArgumentTypeNamed, + Name: "--port ", // Invalid argument name + }, + }, + }, + }, + Remotes: []model.Transport{ + { + Type: model.TransportTypeStdio, // Invalid remote transport type + URL: "", // Missing URL for remote + }, + }, + } + + // Run detailed validation + result := validators.ValidateServerJSONDetailed(serverJSON, false) + + // Verify it's invalid + assert.False(t, result.Valid) + assert.Greater(t, len(result.Issues), 5, "Should have multiple validation issues") + + // Check that we have issues of different types and severities + hasError := false + hasSemantic := false + + for _, issue := range result.Issues { + if issue.Severity == validators.ValidationIssueSeverityError { + hasError = true + } + if issue.Type == validators.ValidationIssueTypeSemantic { + hasSemantic = true + } + } + + assert.True(t, hasError, "Should have error severity issues") + assert.True(t, hasSemantic, "Should have semantic type issues") + + // Verify specific issues exist + issuePaths := make(map[string]bool) + for _, issue := range result.Issues { + issuePaths[issue.Path] = true + } + + // Check for expected issue paths + expectedPaths := []string{ + "name", + "version", + "repository.url", + "websiteUrl", + "packages[0].identifier", + "packages[0].version", + "packages[0].transport.url", + "packages[0].runtimeArguments[0].name", + "remotes[0].type", + "remotes[0].url", + } + + foundPaths := 0 + for _, expectedPath := range expectedPaths { + if issuePaths[expectedPath] { + foundPaths++ + } + } + + assert.Greater(t, foundPaths, 5, "Should have issues at multiple JSON paths") +} + +func TestValidateServerJSONDetailed_ValidServer(t *testing.T) { + // Create a valid server JSON + serverJSON := &apiv0.ServerJSON{ + Name: "com.example.test/valid-server", + Version: "1.0.0", + Description: "A valid test server", + Repository: model.Repository{ + URL: "https://github.com/example/valid-server", + Source: "github", + }, + WebsiteURL: "https://test.example.com", + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeOCI, + RegistryBaseURL: "https://docker.io", + Identifier: "valid-package", + Version: "1.0.0", + Transport: model.Transport{ + Type: model.TransportTypeStdio, + }, + }, + }, + } + + // Run detailed validation + result := validators.ValidateServerJSONDetailed(serverJSON, false) + + // Verify it's valid + assert.True(t, result.Valid) + assert.Empty(t, result.Issues, "Should have no validation issues") +} + +func TestValidateServerJSONDetailed_ContextPaths(t *testing.T) { + // Create a server with nested validation errors to test context paths + serverJSON := &apiv0.ServerJSON{ + Name: "com.example.test/server", + Version: "1.0.0", + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeOCI, + RegistryBaseURL: "https://docker.io", + Identifier: "package-1", + Version: "latest", // Error in first package + Transport: model.Transport{ + Type: model.TransportTypeStdio, + }, + }, + { + RegistryType: model.RegistryTypeOCI, + RegistryBaseURL: "https://docker.io", + Identifier: "package-2", + Version: "2.0.0", + Transport: model.Transport{ + Type: model.TransportTypeStdio, + }, + RuntimeArguments: []model.Argument{ + { + Type: model.ArgumentTypeNamed, + Name: "invalid name", // Error in second package's argument + }, + }, + }, + }, + } + + // Run detailed validation + result := validators.ValidateServerJSONDetailed(serverJSON, false) + + // Verify we have issues at the correct paths + issuePaths := make(map[string]bool) + for _, issue := range result.Issues { + issuePaths[issue.Path] = true + } + + // Should have issues at specific nested paths + assert.True(t, issuePaths["packages[0].version"], "Should have issue at packages[0].version") + assert.True(t, issuePaths["packages[1].runtimeArguments[0].name"], "Should have issue at packages[1].runtimeArguments[0].name") +} diff --git a/internal/validators/validation_types.go b/internal/validators/validation_types.go new file mode 100644 index 000000000..51a635c22 --- /dev/null +++ b/internal/validators/validation_types.go @@ -0,0 +1,98 @@ +package validators + +import "fmt" + +// Validation issue type with constrained values +type ValidationIssueType string + +const ( + ValidationIssueTypeJSON ValidationIssueType = "json" + ValidationIssueTypeSchema ValidationIssueType = "schema" + ValidationIssueTypeSemantic ValidationIssueType = "semantic" + ValidationIssueTypeLinter ValidationIssueType = "linter" +) + +// Validation issue severity with constrained values +type ValidationIssueSeverity string + +const ( + ValidationIssueSeverityError ValidationIssueSeverity = "error" + ValidationIssueSeverityWarning ValidationIssueSeverity = "warning" + ValidationIssueSeverityInfo ValidationIssueSeverity = "info" +) + +// ValidationIssue represents a single validation problem +type ValidationIssue struct { + Type ValidationIssueType `json:"type"` + Path string `json:"path"` // JSON path like "packages[0].transport.url" + Message string `json:"message"` // Error description (extracted from error.Error()) + Severity ValidationIssueSeverity `json:"severity"` + Rule string `json:"rule"` // Rule name like "prefer-transport-configuration" +} + +// ValidationResult contains the results of validation +type ValidationResult struct { + Valid bool `json:"valid"` + Issues []ValidationIssue `json:"issues"` +} + +// ValidationContext tracks the current JSON path during validation +type ValidationContext struct { + path string +} + +// NewValidationIssue creates a validation issue with manual field setting +func NewValidationIssue(issueType ValidationIssueType, path, message string, severity ValidationIssueSeverity, rule string) ValidationIssue { + return ValidationIssue{ + Type: issueType, + Path: path, + Message: message, + Severity: severity, + Rule: rule, + } +} + +// NewValidationIssueFromError creates a validation issue from an existing error +func NewValidationIssueFromError(issueType ValidationIssueType, path string, err error, rule string) ValidationIssue { + return ValidationIssue{ + Type: issueType, + Path: path, + Message: err.Error(), // Extract string from error + Severity: ValidationIssueSeverityError, // Errors are always severity "error" + Rule: rule, + } +} + +// AddIssue adds a validation issue to the result +func (vr *ValidationResult) AddIssue(issue ValidationIssue) { + vr.Issues = append(vr.Issues, issue) + if issue.Severity == ValidationIssueSeverityError { + vr.Valid = false + } +} + +// Merge combines another validation result into this one +func (vr *ValidationResult) Merge(other *ValidationResult) { + vr.Issues = append(vr.Issues, other.Issues...) + if !other.Valid { + vr.Valid = false + } +} + +// Field adds a field name to the context path +func (ctx *ValidationContext) Field(name string) *ValidationContext { + if ctx.path == "" { + return &ValidationContext{path: name} + } + return &ValidationContext{path: ctx.path + "." + name} +} + +// Index adds an array index to the context path +func (ctx *ValidationContext) Index(i int) *ValidationContext { + return &ValidationContext{path: ctx.path + fmt.Sprintf("[%d]", i)} +} + +// String returns the current path as a string +func (ctx *ValidationContext) String() string { + return ctx.path +} diff --git a/internal/validators/validation_types_test.go b/internal/validators/validation_types_test.go new file mode 100644 index 000000000..01a068555 --- /dev/null +++ b/internal/validators/validation_types_test.go @@ -0,0 +1,173 @@ +package validators_test + +import ( + "errors" + "testing" + + "github.com/modelcontextprotocol/registry/internal/validators" + "github.com/stretchr/testify/assert" +) + +func TestValidationIssueTypes(t *testing.T) { + tests := []struct { + name string + issueType validators.ValidationIssueType + expected string + }{ + {"JSON type", validators.ValidationIssueTypeJSON, "json"}, + {"Schema type", validators.ValidationIssueTypeSchema, "schema"}, + {"Semantic type", validators.ValidationIssueTypeSemantic, "semantic"}, + {"Linter type", validators.ValidationIssueTypeLinter, "linter"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, string(tt.issueType)) + }) + } +} + +func TestValidationIssueSeverity(t *testing.T) { + tests := []struct { + name string + severity validators.ValidationIssueSeverity + expected string + }{ + {"Error severity", validators.ValidationIssueSeverityError, "error"}, + {"Warning severity", validators.ValidationIssueSeverityWarning, "warning"}, + {"Info severity", validators.ValidationIssueSeverityInfo, "info"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, string(tt.severity)) + }) + } +} + +func TestNewValidationIssue(t *testing.T) { + issue := validators.NewValidationIssue( + validators.ValidationIssueTypeSemantic, + "repository.url", + "invalid repository URL", + validators.ValidationIssueSeverityError, + "invalid-repository-url", + ) + + assert.Equal(t, validators.ValidationIssueTypeSemantic, issue.Type) + assert.Equal(t, "repository.url", issue.Path) + assert.Equal(t, "invalid repository URL", issue.Message) + assert.Equal(t, validators.ValidationIssueSeverityError, issue.Severity) + assert.Equal(t, "invalid-repository-url", issue.Rule) +} + +func TestNewValidationIssueFromError(t *testing.T) { + err := errors.New("invalid repository URL: https://bad-url.com") + issue := validators.NewValidationIssueFromError( + validators.ValidationIssueTypeSemantic, + "repository.url", + err, + "invalid-repository-url", + ) + + assert.Equal(t, validators.ValidationIssueTypeSemantic, issue.Type) + assert.Equal(t, "repository.url", issue.Path) + assert.Equal(t, "invalid repository URL: https://bad-url.com", issue.Message) + assert.Equal(t, validators.ValidationIssueSeverityError, issue.Severity) + assert.Equal(t, "invalid-repository-url", issue.Rule) +} + +func TestValidationResultAddIssue(t *testing.T) { + result := &validators.ValidationResult{Valid: true, Issues: []validators.ValidationIssue{}} + + // Add a warning issue - should not affect validity + warningIssue := validators.NewValidationIssue( + validators.ValidationIssueTypeLinter, + "description", + "consider adding a description", + validators.ValidationIssueSeverityWarning, + "descriptive-naming", + ) + result.AddIssue(warningIssue) + + assert.True(t, result.Valid) + assert.Len(t, result.Issues, 1) + + // Add an error issue - should make invalid + errorIssue := validators.NewValidationIssue( + validators.ValidationIssueTypeSemantic, + "name", + "server name is required", + validators.ValidationIssueSeverityError, + "missing-server-name", + ) + result.AddIssue(errorIssue) + + assert.False(t, result.Valid) + assert.Len(t, result.Issues, 2) +} + +func TestValidationResultMerge(t *testing.T) { + result1 := &validators.ValidationResult{Valid: true, Issues: []validators.ValidationIssue{}} + result2 := &validators.ValidationResult{Valid: false, Issues: []validators.ValidationIssue{}} + + // Add issues to both + issue1 := validators.NewValidationIssue( + validators.ValidationIssueTypeSemantic, + "name", + "server name is required", + validators.ValidationIssueSeverityError, + "missing-server-name", + ) + result1.AddIssue(issue1) + + issue2 := validators.NewValidationIssue( + validators.ValidationIssueTypeSchema, + "version", + "version must be a string", + validators.ValidationIssueSeverityError, + "schema-validation", + ) + result2.AddIssue(issue2) + + // Merge result2 into result1 + result1.Merge(result2) + + assert.False(t, result1.Valid) // Should be invalid because result2 was invalid + assert.Len(t, result1.Issues, 2) // Should have both issues +} + +func TestValidationContext(t *testing.T) { + // Test empty context + ctx := &validators.ValidationContext{} + assert.Equal(t, "", ctx.String()) + + // Test field addition + ctx = ctx.Field("repository") + assert.Equal(t, "repository", ctx.String()) + + // Test nested field + ctx = ctx.Field("url") + assert.Equal(t, "repository.url", ctx.String()) + + // Test array index + ctx = &validators.ValidationContext{} + ctx = ctx.Field("packages").Index(0).Field("transport") + assert.Equal(t, "packages[0].transport", ctx.String()) + + // Test multiple array indices + ctx = &validators.ValidationContext{} + ctx = ctx.Field("packages").Index(0).Field("environmentVariables").Index(1).Field("name") + assert.Equal(t, "packages[0].environmentVariables[1].name", ctx.String()) +} + +func TestValidationContextImmutability(t *testing.T) { + // Test that context operations return new instances + ctx1 := &validators.ValidationContext{} + ctx2 := ctx1.Field("repository") + ctx3 := ctx2.Field("url") + + assert.Equal(t, "", ctx1.String()) + assert.Equal(t, "repository", ctx2.String()) + assert.Equal(t, "repository.url", ctx3.String()) +} diff --git a/internal/validators/validators.go b/internal/validators/validators.go index 25cdf324b..f8f21d750 100644 --- a/internal/validators/validators.go +++ b/internal/validators/validators.go @@ -53,145 +53,227 @@ var ( ) func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { + result := ValidateServerJSONDetailed(serverJSON, false) + if !result.Valid { + // Return the first error issue + for _, issue := range result.Issues { + if issue.Severity == ValidationIssueSeverityError { + return fmt.Errorf("%s", issue.Message) + } + } + } + return nil +} + +// ValidateServerJSONDetailed performs exhaustive validation and returns all issues found +// If validateSchema is true, it will also validate against server.schema.json +func ValidateServerJSONDetailed(serverJSON *apiv0.ServerJSON, validateSchema bool) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + ctx := &ValidationContext{} + + // Schema validation first (if requested) - catches structural issues early + if validateSchema { + schemaResult := validateServerJSONSchema(serverJSON) + result.Merge(schemaResult) + // If schema validation fails, we might still want to run semantic validation + // to provide additional context, but schema errors take precedence + } + // Validate server name exists and format if _, err := parseServerName(*serverJSON); err != nil { - return err + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.Field("name").String(), + err, + "invalid-server-name", + ) + result.AddIssue(issue) } // Validate top-level server version is a specific version (not a range) & not "latest" - if err := validateVersion(serverJSON.Version); err != nil { - return err - } + versionResult := validateVersion(ctx.Field("version"), serverJSON.Version) + result.Merge(versionResult) // Validate repository - if err := validateRepository(&serverJSON.Repository); err != nil { - return err - } + repoResult := validateRepository(ctx.Field("repository"), &serverJSON.Repository) + result.Merge(repoResult) // Validate website URL if provided - if err := validateWebsiteURL(serverJSON.WebsiteURL); err != nil { - return err - } + websiteResult := validateWebsiteURL(ctx.Field("websiteUrl"), serverJSON.WebsiteURL) + result.Merge(websiteResult) // Validate all packages (basic field validation) // Detailed package validation (including registry checks) is done during publish - for _, pkg := range serverJSON.Packages { - if err := validatePackageField(&pkg); err != nil { - return err - } + for i, pkg := range serverJSON.Packages { + pkgResult := validatePackageField(ctx.Field("packages").Index(i), &pkg) + result.Merge(pkgResult) } // Validate all remotes - for _, remote := range serverJSON.Remotes { - if err := validateRemoteTransport(&remote); err != nil { - return err - } + for i, remote := range serverJSON.Remotes { + remoteResult := validateRemoteTransport(ctx.Field("remotes").Index(i), &remote) + result.Merge(remoteResult) } // Validate reverse-DNS namespace matching for remote URLs - if err := validateRemoteNamespaceMatch(*serverJSON); err != nil { - return err - } + remoteNamespaceResult := validateRemoteNamespaceMatch(ctx.Field("remotes"), *serverJSON) + result.Merge(remoteNamespaceResult) // Validate reverse-DNS namespace matching for website URL - if err := validateWebsiteURLNamespaceMatch(*serverJSON); err != nil { - return err - } + websiteNamespaceResult := validateWebsiteURLNamespaceMatch(ctx.Field("websiteUrl"), *serverJSON) + result.Merge(websiteNamespaceResult) - return nil + return result } -func validateRepository(obj *model.Repository) error { +func validateRepository(ctx *ValidationContext, obj *model.Repository) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + // Skip validation for empty repository (optional field) if obj.URL == "" && obj.Source == "" { - return nil + return result } // validate the repository source repoSource := RepositorySource(obj.Source) if !IsValidRepositoryURL(repoSource, obj.URL) { - return fmt.Errorf("%w: %s", ErrInvalidRepositoryURL, obj.URL) + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.Field("url").String(), + fmt.Errorf("%w: %s", ErrInvalidRepositoryURL, obj.URL), + "invalid-repository-url", + ) + result.AddIssue(issue) } // validate subfolder if present if obj.Subfolder != "" && !IsValidSubfolderPath(obj.Subfolder) { - return fmt.Errorf("%w: %s", ErrInvalidSubfolderPath, obj.Subfolder) + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.Field("subfolder").String(), + fmt.Errorf("%w: %s", ErrInvalidSubfolderPath, obj.Subfolder), + "invalid-subfolder-path", + ) + result.AddIssue(issue) } - return nil + return result } -func validateWebsiteURL(websiteURL string) error { +func validateWebsiteURL(ctx *ValidationContext, websiteURL string) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + // Skip validation if website URL is not provided (optional field) if websiteURL == "" { - return nil + return result } // Parse the URL to ensure it's valid parsedURL, err := url.Parse(websiteURL) if err != nil { - return fmt.Errorf("invalid websiteUrl: %w", err) + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.String(), + fmt.Errorf("invalid websiteUrl: %w", err), + "invalid-website-url", + ) + result.AddIssue(issue) + return result } // Ensure it's an absolute URL with valid scheme if !parsedURL.IsAbs() { - return fmt.Errorf("websiteUrl must be absolute (include scheme): %s", websiteURL) + issue := NewValidationIssue( + ValidationIssueTypeSemantic, + ctx.String(), + fmt.Sprintf("websiteUrl must be absolute (include scheme): %s", websiteURL), + ValidationIssueSeverityError, + "website-url-must-be-absolute", + ) + result.AddIssue(issue) } // Only allow HTTP/HTTPS schemes for security if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - return fmt.Errorf("websiteUrl must use http or https scheme: %s", websiteURL) + issue := NewValidationIssue( + ValidationIssueTypeSemantic, + ctx.String(), + fmt.Sprintf("websiteUrl must use http or https scheme: %s", websiteURL), + ValidationIssueSeverityError, + "website-url-invalid-scheme", + ) + result.AddIssue(issue) } - return nil + return result } -func validatePackageField(obj *model.Package) error { +func validatePackageField(ctx *ValidationContext, obj *model.Package) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + + // Validate identifier has no spaces if !HasNoSpaces(obj.Identifier) { - return ErrPackageNameHasSpaces + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.Field("identifier").String(), + ErrPackageNameHasSpaces, + "package-name-has-spaces", + ) + result.AddIssue(issue) } // Validate version string - if err := validateVersion(obj.Version); err != nil { - return err - } + versionResult := validateVersion(ctx.Field("version"), obj.Version) + result.Merge(versionResult) // Validate runtime arguments - for _, arg := range obj.RuntimeArguments { - if err := validateArgument(&arg); err != nil { - return fmt.Errorf("invalid runtime argument: %w", err) - } + for i, arg := range obj.RuntimeArguments { + argResult := validateArgument(ctx.Field("runtimeArguments").Index(i), &arg) + result.Merge(argResult) } // Validate package arguments - for _, arg := range obj.PackageArguments { - if err := validateArgument(&arg); err != nil { - return fmt.Errorf("invalid package argument: %w", err) - } + for i, arg := range obj.PackageArguments { + argResult := validateArgument(ctx.Field("packageArguments").Index(i), &arg) + result.Merge(argResult) } // Validate transport with template variable support availableVariables := collectAvailableVariables(obj) - if err := validatePackageTransport(&obj.Transport, availableVariables); err != nil { - return fmt.Errorf("invalid transport: %w", err) - } + transportResult := validatePackageTransport(ctx.Field("transport"), &obj.Transport, availableVariables) + result.Merge(transportResult) - return nil + return result } // validateVersion validates the version string. // NB: we decided that we would not enforce strict semver for version strings -func validateVersion(version string) error { +func validateVersion(ctx *ValidationContext, version string) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + if version == "latest" { - return ErrReservedVersionString + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.String(), + ErrReservedVersionString, + "reserved-version-string", + ) + result.AddIssue(issue) + return result } // Reject semver range-like inputs if looksLikeVersionRange(version) { - return fmt.Errorf("%w: %q", ErrVersionLooksLikeRange, version) + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.String(), + fmt.Errorf("%w: %q", ErrVersionLooksLikeRange, version), + "version-looks-like-range", + ) + result.AddIssue(issue) } - return nil + return result } // looksLikeVersionRange detects common semver range syntaxes and wildcard patterns. @@ -227,25 +309,34 @@ func looksLikeVersionRange(version string) bool { } // validateArgument validates argument details -func validateArgument(obj *model.Argument) error { +func validateArgument(ctx *ValidationContext, obj *model.Argument) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + if obj.Type == model.ArgumentTypeNamed { // Validate named argument name format - if err := validateNamedArgumentName(obj.Name); err != nil { - return err - } + nameResult := validateNamedArgumentName(ctx.Field("name"), obj.Name) + result.Merge(nameResult) // Validate value and default don't start with the name - if err := validateArgumentValueFields(obj.Name, obj.Value, obj.Default); err != nil { - return err - } + valueResult := validateArgumentValueFields(ctx, obj.Name, obj.Value, obj.Default) + result.Merge(valueResult) } - return nil + return result } -func validateNamedArgumentName(name string) error { +func validateNamedArgumentName(ctx *ValidationContext, name string) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + // Check if name is required for named arguments if name == "" { - return ErrNamedArgumentNameRequired + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.String(), + ErrNamedArgumentNameRequired, + "named-argument-name-required", + ) + result.AddIssue(issue) + return result } // Check for invalid characters that suggest embedded values or descriptions @@ -253,23 +344,43 @@ func validateNamedArgumentName(name string) error { // Invalid: "--directory ", "--port 8080" if strings.Contains(name, "<") || strings.Contains(name, ">") || strings.Contains(name, " ") || strings.Contains(name, "$") { - return fmt.Errorf("%w: %s", ErrInvalidNamedArgumentName, name) + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.String(), + fmt.Errorf("%w: %s", ErrInvalidNamedArgumentName, name), + "invalid-named-argument-name", + ) + result.AddIssue(issue) } - return nil + return result } -func validateArgumentValueFields(name, value, defaultValue string) error { +func validateArgumentValueFields(ctx *ValidationContext, name, value, defaultValue string) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + // Check if value starts with the argument name (using startsWith, not contains) if value != "" && strings.HasPrefix(value, name) { - return fmt.Errorf("%w: value starts with argument name '%s': %s", ErrArgumentValueStartsWithName, name, value) + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.Field("value").String(), + fmt.Errorf("%w: value starts with argument name '%s': %s", ErrArgumentValueStartsWithName, name, value), + "argument-value-starts-with-name", + ) + result.AddIssue(issue) } if defaultValue != "" && strings.HasPrefix(defaultValue, name) { - return fmt.Errorf("%w: default starts with argument name '%s': %s", ErrArgumentDefaultStartsWithName, name, defaultValue) + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.Field("default").String(), + fmt.Errorf("%w: default starts with argument name '%s': %s", ErrArgumentDefaultStartsWithName, name, defaultValue), + "argument-default-starts-with-name", + ) + result.AddIssue(issue) } - return nil + return result } // collectAvailableVariables collects all available template variables from a package @@ -305,53 +416,110 @@ func collectAvailableVariables(pkg *model.Package) []string { } // validatePackageTransport validates a package's transport with templating support -func validatePackageTransport(transport *model.Transport, availableVariables []string) error { +func validatePackageTransport(ctx *ValidationContext, transport *model.Transport, availableVariables []string) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + // Validate transport type is supported switch transport.Type { case model.TransportTypeStdio: // Validate that URL is empty for stdio transport if transport.URL != "" { - return fmt.Errorf("url must be empty for %s transport type, got: %s", transport.Type, transport.URL) + issue := NewValidationIssue( + ValidationIssueTypeSemantic, + ctx.Field("url").String(), + fmt.Sprintf("url must be empty for %s transport type, got: %s", transport.Type, transport.URL), + ValidationIssueSeverityError, + "stdio-transport-url-not-empty", + ) + result.AddIssue(issue) } - return nil case model.TransportTypeStreamableHTTP, model.TransportTypeSSE: // URL is required for streamable-http and sse if transport.URL == "" { - return fmt.Errorf("url is required for %s transport type", transport.Type) - } - // Validate URL format with template variable support - if !IsValidTemplatedURL(transport.URL, availableVariables, true) { - // Check if it's a template variable issue or basic URL issue - templateVars := extractTemplateVariables(transport.URL) - if len(templateVars) > 0 { - return fmt.Errorf("%w: template variables in URL %s reference undefined variables. Available variables: %v", - ErrInvalidRemoteURL, transport.URL, availableVariables) + issue := NewValidationIssue( + ValidationIssueTypeSemantic, + ctx.Field("url").String(), + fmt.Sprintf("url is required for %s transport type", transport.Type), + ValidationIssueSeverityError, + "streamable-transport-url-required", + ) + result.AddIssue(issue) + } else { + // Validate URL format with template variable support + if !IsValidTemplatedURL(transport.URL, availableVariables, true) { + // Check if it's a template variable issue or basic URL issue + templateVars := extractTemplateVariables(transport.URL) + var err error + if len(templateVars) > 0 { + err = fmt.Errorf("%w: template variables in URL %s reference undefined variables. Available variables: %v", + ErrInvalidRemoteURL, transport.URL, availableVariables) + } else { + err = fmt.Errorf("%w: %s", ErrInvalidRemoteURL, transport.URL) + } + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.Field("url").String(), + err, + "invalid-templated-url", + ) + result.AddIssue(issue) } - return fmt.Errorf("%w: %s", ErrInvalidRemoteURL, transport.URL) } - return nil default: - return fmt.Errorf("unsupported transport type: %s", transport.Type) + issue := NewValidationIssue( + ValidationIssueTypeSemantic, + ctx.Field("type").String(), + fmt.Sprintf("unsupported transport type: %s", transport.Type), + ValidationIssueSeverityError, + "unsupported-transport-type", + ) + result.AddIssue(issue) } + + return result } // validateRemoteTransport validates a remote transport (no templating allowed) -func validateRemoteTransport(obj *model.Transport) error { +func validateRemoteTransport(ctx *ValidationContext, obj *model.Transport) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + // Validate transport type is supported - remotes only support streamable-http and sse switch obj.Type { case model.TransportTypeStreamableHTTP, model.TransportTypeSSE: // URL is required for streamable-http and sse if obj.URL == "" { - return fmt.Errorf("url is required for %s transport type", obj.Type) - } - // Validate URL format (no templates allowed for remotes, no localhost) - if !IsValidRemoteURL(obj.URL) { - return fmt.Errorf("%w: %s", ErrInvalidRemoteURL, obj.URL) + issue := NewValidationIssue( + ValidationIssueTypeSemantic, + ctx.Field("url").String(), + fmt.Sprintf("url is required for %s transport type", obj.Type), + ValidationIssueSeverityError, + "remote-transport-url-required", + ) + result.AddIssue(issue) + } else { + // Validate URL format (no templates allowed for remotes, no localhost) + if !IsValidRemoteURL(obj.URL) { + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.Field("url").String(), + fmt.Errorf("%w: %s", ErrInvalidRemoteURL, obj.URL), + "invalid-remote-url", + ) + result.AddIssue(issue) + } } - return nil default: - return fmt.Errorf("unsupported transport type for remotes: %s (only streamable-http and sse are supported)", obj.Type) + issue := NewValidationIssue( + ValidationIssueTypeSemantic, + ctx.Field("type").String(), + fmt.Sprintf("unsupported transport type for remotes: %s (only streamable-http and sse are supported)", obj.Type), + ValidationIssueSeverityError, + "unsupported-remote-transport-type", + ) + result.AddIssue(issue) } + + return result } // ValidatePublishRequest validates a complete publish request including extensions @@ -441,31 +609,46 @@ func parseServerName(serverJSON apiv0.ServerJSON) (string, error) { } // validateRemoteNamespaceMatch validates that remote URLs match the reverse-DNS namespace -func validateRemoteNamespaceMatch(serverJSON apiv0.ServerJSON) error { +func validateRemoteNamespaceMatch(ctx *ValidationContext, serverJSON apiv0.ServerJSON) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} namespace := serverJSON.Name - for _, remote := range serverJSON.Remotes { + for i, remote := range serverJSON.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) + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.Index(i).Field("url").String(), + fmt.Errorf("remote URL %s does not match namespace %s: %w", remote.URL, namespace, err), + "remote-url-namespace-mismatch", + ) + result.AddIssue(issue) } } - return nil + return result } // validateWebsiteURLNamespaceMatch validates that website URL matches the reverse-DNS namespace -func validateWebsiteURLNamespaceMatch(serverJSON apiv0.ServerJSON) error { +func validateWebsiteURLNamespaceMatch(ctx *ValidationContext, serverJSON apiv0.ServerJSON) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + // Skip validation if website URL is not provided if serverJSON.WebsiteURL == "" { - return nil + return result } namespace := serverJSON.Name if err := validateRemoteURLMatchesNamespace(serverJSON.WebsiteURL, namespace); err != nil { - return fmt.Errorf("websiteUrl %s does not match namespace %s: %w", serverJSON.WebsiteURL, namespace, err) + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.String(), + fmt.Errorf("websiteUrl %s does not match namespace %s: %w", serverJSON.WebsiteURL, namespace, err), + "website-url-namespace-mismatch", + ) + result.AddIssue(issue) } - return nil + return result } // validateRemoteURLMatchesNamespace checks if a remote URL's hostname matches the publisher domain from the namespace From 0d3950db32dc9233c790436452f5a3ad55b44c45 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 5 Oct 2025 22:08:00 -0700 Subject: [PATCH 02/18] Embedded the schema to make it available at runtime. --- cmd/publisher/commands/init.go | 10 +- cmd/publisher/commands/publish.go | 18 +- cmd/publisher/commands/validate.go | 17 +- internal/validators/schema.go | 48 +- internal/validators/schema/server.schema.json | 455 ++++++++++++++++++ 5 files changed, 516 insertions(+), 32 deletions(-) create mode 100644 internal/validators/schema/server.schema.json diff --git a/cmd/publisher/commands/init.go b/cmd/publisher/commands/init.go index 9c5db7502..fc7610a86 100644 --- a/cmd/publisher/commands/init.go +++ b/cmd/publisher/commands/init.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/modelcontextprotocol/registry/internal/validators" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" "github.com/modelcontextprotocol/registry/pkg/model" ) @@ -300,8 +301,15 @@ func createServerJSON( } // Create server structure + // Get current schema version from embedded schema + currentSchema, err := validators.GetCurrentSchemaVersion() + if err != nil { + // Should never happen (schema is embedded) + panic(fmt.Sprintf("failed to get embedded schema version: %v", err)) + } + return apiv0.ServerJSON{ - Schema: "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json", + Schema: currentSchema, Name: name, Description: description, Repository: model.Repository{ diff --git a/cmd/publisher/commands/publish.go b/cmd/publisher/commands/publish.go index 0f6b959e4..2138c3ec5 100644 --- a/cmd/publisher/commands/publish.go +++ b/cmd/publisher/commands/publish.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" + "github.com/modelcontextprotocol/registry/internal/validators" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" ) @@ -39,13 +40,24 @@ func PublishCommand(args []string) error { // Check for deprecated schema and recommend migration // Allow empty schema (will use default) but reject old schemas - if serverJSON.Schema != "" && !strings.Contains(serverJSON.Schema, "2025-09-29") { - return fmt.Errorf(`deprecated schema detected :%s. + if serverJSON.Schema != "" { + // Get current schema version from embedded schema + currentSchema, err := validators.GetCurrentSchemaVersion() + if err != nil { + // Schema is embedded, so this should never happen + return fmt.Errorf("failed to get current schema version: %w", err) + } + + if serverJSON.Schema != currentSchema { + return fmt.Errorf(`deprecated schema detected: %s. + +Expected current schema: %s Migrate to the current schema format for new servers. 📋 Migration checklist: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md#migration-checklist-for-publishers -📖 Full changelog with examples: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md`, serverJSON.Schema) +📖 Full changelog with examples: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md`, serverJSON.Schema, currentSchema) + } } // Load saved token diff --git a/cmd/publisher/commands/validate.go b/cmd/publisher/commands/validate.go index a77d2e203..0cbcc07bd 100644 --- a/cmd/publisher/commands/validate.go +++ b/cmd/publisher/commands/validate.go @@ -37,13 +37,24 @@ func ValidateCommand(args []string) error { // Check for deprecated schema and recommend migration // Allow empty schema (will use default) but reject old schemas - if serverJSON.Schema != "" && !strings.Contains(serverJSON.Schema, "2025-09-29") { - return fmt.Errorf(`deprecated schema detected: %s. + if serverJSON.Schema != "" { + // Get current schema version from embedded schema + currentSchema, err := validators.GetCurrentSchemaVersion() + if err != nil { + // Should never happen (schema is embedded) + return fmt.Errorf("failed to get current schema version: %w", err) + } + + if serverJSON.Schema != currentSchema { + return fmt.Errorf(`deprecated schema detected: %s. + +Expected current schema: %s Migrate to the current schema format for new servers. 📋 Migration checklist: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md#migration-checklist-for-publishers -📖 Full changelog with examples: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md`, serverJSON.Schema) +📖 Full changelog with examples: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md`, serverJSON.Schema, currentSchema) + } } // Run detailed validation (this is the whole point of the validate command) diff --git a/internal/validators/schema.go b/internal/validators/schema.go index b626b39b2..4ee21637c 100644 --- a/internal/validators/schema.go +++ b/internal/validators/schema.go @@ -2,44 +2,42 @@ package validators import ( "bytes" + _ "embed" "encoding/json" "fmt" - "os" "strings" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" "github.com/santhosh-tekuri/jsonschema/v5" ) -// validateServerJSONSchema validates the server JSON against server.schema.json using jsonschema -func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { - result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} +//go:embed schema/*.json +var embeddedSchema []byte - // Load the schema file - find it relative to the binary's location - schemaPath := "docs/reference/server-json/server.schema.json" - - // If running from bin/ directory, go up one level to find the schema - if _, err := os.Stat(schemaPath); os.IsNotExist(err) { - schemaPath = "../docs/reference/server-json/server.schema.json" +// GetCurrentSchemaVersion extracts the $id field from the embedded schema +func GetCurrentSchemaVersion() (string, error) { + var schema map[string]any + if err := json.Unmarshal(embeddedSchema, &schema); err != nil { + return "", fmt.Errorf("failed to parse embedded schema: %w", err) } - // Try to find the schema file relative to the current working directory - schemaData, err := os.ReadFile(schemaPath) - if err != nil { - // If we can't load the schema, return an error - schema validation is required - issue := NewValidationIssue( - ValidationIssueTypeSchema, - "", - fmt.Sprintf("failed to load schema file '%s': %v", schemaPath, err), - ValidationIssueSeverityError, - "schema-load-error", - ) - result.AddIssue(issue) - return result + id, ok := schema["$id"].(string) + if !ok { + return "", fmt.Errorf("embedded schema missing $id field") } + return id, nil +} + +// validateServerJSONSchema validates the server JSON against server.schema.json using jsonschema +func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { + result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + + // Use embedded schema - no file system access needed + schemaData := embeddedSchema + // Parse the schema - var schema map[string]interface{} + var schema map[string]any if err := json.Unmarshal(schemaData, &schema); err != nil { // If we can't parse the schema, return an error issue := NewValidationIssue( @@ -67,7 +65,7 @@ func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { return result } - var serverMap map[string]interface{} + var serverMap map[string]any if err := json.Unmarshal(serverData, &serverMap); err != nil { issue := NewValidationIssue( ValidationIssueTypeJSON, diff --git a/internal/validators/schema/server.schema.json b/internal/validators/schema/server.schema.json new file mode 100644 index 000000000..72cf6f12c --- /dev/null +++ b/internal/validators/schema/server.schema.json @@ -0,0 +1,455 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json", + "title": "MCP Server Detail", + "$ref": "#/definitions/ServerDetail", + "definitions": { + "Repository": { + "type": "object", + "description": "Repository metadata for the MCP server source code. Enables users and security experts to inspect the code, improving transparency.", + "required": [ + "url", + "source" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Repository URL for browsing source code. Should support both web browsing and git clone operations.", + "example": "https://github.com/modelcontextprotocol/servers" + }, + "source": { + "type": "string", + "description": "Repository hosting service identifier. Used by registries to determine validation and API access methods.", + "example": "github" + }, + "id": { + "type": "string", + "description": "Repository identifier from the hosting service (e.g., GitHub repo ID). Owned and determined by the source forge. Should remain stable across repository renames and may be used to detect repository resurrection attacks - if a repository is deleted and recreated, the ID should change. For GitHub, use: gh api repos// --jq '.id'", + "example": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9" + }, + "subfolder": { + "type": "string", + "description": "Optional relative path from repository root to the server location within a monorepo or nested package structure. Must be a clean relative path.", + "example": "src/everything" + } + } + }, + "Package": { + "type": "object", + "additionalProperties": false, + "required": [ + "registryType", + "identifier", + "version", + "transport" + ], + "properties": { + "registryType": { + "type": "string", + "description": "Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb')", + "examples": ["npm", "pypi", "oci", "nuget", "mcpb"] + }, + "registryBaseUrl": { + "type": "string", + "format": "uri", + "description": "Base URL of the package registry", + "examples": ["https://registry.npmjs.org", "https://pypi.org", "https://docker.io", "https://api.nuget.org", "https://github.com", "https://gitlab.com"] + }, + "identifier": { + "type": "string", + "description": "Package identifier - either a package name (for registries) or URL (for direct downloads)", + "examples": ["@modelcontextprotocol/server-brave-search", "https://github.com/example/releases/download/v1.0.0/package.mcpb"] + }, + "version": { + "type": "string", + "description": "Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*').", + "not": { + "const": "latest" + }, + "example": "1.0.2", + "minLength": 1 + }, + "fileSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "SHA-256 hash of the package file for integrity verification. Required for MCPB packages and optional for other package types. Authors are responsible for generating correct SHA-256 hashes when creating server.json. If present, MCP clients must validate the downloaded file matches the hash before running packages to ensure file integrity.", + "example": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce" + }, + "runtimeHint": { + "type": "string", + "description": "A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtimeArguments` are present.", + "examples": [ + "npx", + "uvx", + "docker", + "dnx" + ] + }, + "transport": { + "anyOf": [ + { + "$ref": "#/definitions/StdioTransport" + }, + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ], + "description": "Transport protocol configuration for the package" + }, + "runtimeArguments": { + "type": "array", + "description": "A list of arguments to be passed to the package's runtime command (such as docker or npx). The `runtimeHint` field should be provided when `runtimeArguments` are present.", + "items": { + "$ref": "#/definitions/Argument" + } + }, + "packageArguments": { + "type": "array", + "description": "A list of arguments to be passed to the package's binary.", + "items": { + "$ref": "#/definitions/Argument" + } + }, + "environmentVariables": { + "type": "array", + "description": "A mapping of environment variables to be set when running the package.", + "items": { + "$ref": "#/definitions/KeyValueInput" + } + } + } + }, + "Input": { + "type": "object", + "properties": { + "description": { + "description": "A description of the input, which clients can use to provide context to the user.", + "type": "string" + }, + "isRequired": { + "type": "boolean", + "default": false + }, + "format": { + "type": "string", + "description": "Specifies the input format. Supported values include `filepath`, which should be interpreted as a file on the user's filesystem.\n\nWhen the input is converted to a string, booleans should be represented by the strings \"true\" and \"false\", and numbers should be represented as decimal values.", + "enum": [ + "string", + "number", + "boolean", + "filepath" + ], + "default": "string" + }, + "value": { + "type": "string", + "description": "The default value for the input. If this is not set, the user may be prompted to provide a value. If a value is set, it should not be configurable by end users.\n\nIdentifiers wrapped in `{curly_braces}` will be replaced with the corresponding properties from the input `variables` map. If an identifier in braces is not found in `variables`, or if `variables` is not provided, the `{curly_braces}` substring should remain unchanged.\n" + }, + "isSecret": { + "type": "boolean", + "description": "Indicates whether the input is a secret value (e.g., password, token). If true, clients should handle the value securely.", + "default": false + }, + "default": { + "type": "string", + "description": "The default value for the input." + }, + "choices": { + "type": "array", + "description": "A list of possible values for the input. If provided, the user must select one of these values.", + "items": { + "type": "string" + }, + "example": [] + } + } + }, + "InputWithVariables": { + "allOf": [ + { + "$ref": "#/definitions/Input" + }, + { + "type": "object", + "properties": { + "variables": { + "type": "object", + "description": "A map of variable names to their values. Keys in the input `value` that are wrapped in `{curly_braces}` will be replaced with the corresponding variable values.", + "additionalProperties": { + "$ref": "#/definitions/Input" + } + } + } + } + ] + }, + "PositionalArgument": { + "description": "A positional input is a value inserted verbatim into the command line.", + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "positional" + ], + "example": "positional" + }, + "valueHint": { + "type": "string", + "description": "An identifier-like hint for the value. This is not part of the command line, but can be used by client configuration and to provide hints to users.", + "example": "file_path" + }, + "isRepeated": { + "type": "boolean", + "description": "Whether the argument can be repeated multiple times in the command line.", + "default": false + } + }, + "anyOf": [ + { + "required": [ + "valueHint" + ] + }, + { + "required": [ + "value" + ] + } + ] + } + ] + }, + "NamedArgument": { + "description": "A command-line `--flag={value}`.", + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "type": "object", + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "named" + ], + "example": "named" + }, + "name": { + "type": "string", + "description": "The flag name, including any leading dashes.", + "example": "--port" + }, + "isRepeated": { + "type": "boolean", + "description": "Whether the argument can be repeated multiple times.", + "default": false + } + } + } + ] + }, + "KeyValueInput": { + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the header or environment variable.", + "example": "SOME_VARIABLE" + } + } + } + ] + }, + "Argument": { + "description": "Warning: Arguments construct command-line parameters that may contain user-provided input. This creates potential command injection risks if clients execute commands in a shell environment. For example, a malicious argument value like ';rm -rf ~/Development' could execute dangerous commands. Clients should prefer non-shell execution methods (e.g., posix_spawn) when possible to eliminate injection risks entirely. Where not possible, clients should obtain consent from users or agents to run the resolved command before execution.", + "anyOf": [ + { + "$ref": "#/definitions/PositionalArgument" + }, + { + "$ref": "#/definitions/NamedArgument" + } + ] + }, + "StdioTransport": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "stdio" + ], + "description": "Transport type", + "example": "stdio" + } + } + }, + "StreamableHttpTransport": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "streamable-http" + ], + "description": "Transport type", + "example": "streamable-http" + }, + "url": { + "type": "string", + "description": "URL template for the streamable-http transport. Variables in {curly_braces} reference argument valueHints, argument names, or environment variable names. After variable substitution, this should produce a valid URI.", + "example": "https://api.example.com/mcp" + }, + "headers": { + "type": "array", + "description": "HTTP headers to include", + "items": { + "$ref": "#/definitions/KeyValueInput" + } + } + } + }, + "SseTransport": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "sse" + ], + "description": "Transport type", + "example": "sse" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Server-Sent Events endpoint URL", + "example": "https://mcp-fs.example.com/sse" + }, + "headers": { + "type": "array", + "description": "HTTP headers to include", + "items": { + "$ref": "#/definitions/KeyValueInput" + } + } + } + }, + "ServerDetail": { + "description": "Schema for a static representation of an MCP server. Used in various contexts related to discovery, installation, and configuration.", + "type": "object", + "required": [ + "name", + "description", + "version" + ], + "properties": { + "name": { + "type": "string", + "description": "Server name in reverse-DNS format. Must contain exactly one forward slash separating namespace from server name.", + "example": "io.github.user/weather", + "pattern": "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$", + "minLength": 3, + "maxLength": 200 + }, + "description": { + "type": "string", + "description": "Clear human-readable explanation of server functionality. Should focus on capabilities, not implementation details.", + "example": "MCP server providing weather data and forecasts via OpenWeatherMap API", + "minLength": 1, + "maxLength": 100 + }, + "repository": { + "$ref": "#/definitions/Repository", + "description": "Optional repository metadata for the MCP server source code. Recommended for transparency and security inspection." + }, + "version": { + "type": "string", + "maxLength": 255, + "example": "1.0.2", + "description": "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification. Non-semantic versions are allowed but may not sort predictably. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*')." + }, + "websiteUrl": { + "type": "string", + "format": "uri", + "description": "Optional URL to the server's homepage, documentation, or project website. This provides a central link for users to learn more about the server. Particularly useful when the server has custom installation instructions or setup requirements.", + "example": "https://modelcontextprotocol.io/examples" + }, + "$schema": { + "type": "string", + "format": "uri", + "description": "JSON Schema URI for this server.json format", + "example": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json" + }, + "packages": { + "type": "array", + "items": { + "$ref": "#/definitions/Package" + } + }, + "remotes": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ] + } + }, + "_meta": { + "type": "object", + "description": "Extension metadata using reverse DNS namespacing for vendor-specific data", + "additionalProperties": true, + "properties": { + "io.modelcontextprotocol.registry/publisher-provided": { + "type": "object", + "description": "Publisher-provided metadata for downstream registries", + "additionalProperties": true + } + } + } + } + } + } +} From 0ad650e39be2502835b73cc4094679d92bc78aee Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 6 Oct 2025 19:31:39 -0700 Subject: [PATCH 03/18] Implemented schema "reference" (constraint path and full path with deferenced $refs). --- ENHANCED_VALIDATION_DESIGN.md | 409 +++++++++++++++--- cmd/publisher/commands/validate.go | 10 +- internal/validators/schema.go | 145 ++++++- internal/validators/schema/server.schema.json | 58 ++- .../validators/validation_detailed_test.go | 74 ++++ internal/validators/validation_types.go | 34 +- internal/validators/validation_types_test.go | 4 +- 7 files changed, 615 insertions(+), 119 deletions(-) diff --git a/ENHANCED_VALIDATION_DESIGN.md b/ENHANCED_VALIDATION_DESIGN.md index 9692ece32..519615077 100644 --- a/ENHANCED_VALIDATION_DESIGN.md +++ b/ENHANCED_VALIDATION_DESIGN.md @@ -58,11 +58,11 @@ const ( ) type ValidationIssue struct { - Type ValidationIssueType `json:"type"` - Path string `json:"path"` // JSON path like "packages[0].transport.url" - Message string `json:"message"` // Error description (extracted from error.Error()) - Severity ValidationIssueSeverity `json:"severity"` - Rule string `json:"rule"` // Rule name like "prefer-transport-configuration" + Type ValidationIssueType `json:"type"` + Path string `json:"path"` // JSON path like "packages[0].transport.url" + Message string `json:"message"` // Error description (extracted from error.Error()) + Severity ValidationIssueSeverity `json:"severity"` + Reference string `json:"reference"` // Schema rule path or rule name like "prefer-transport-configuration" } type ValidationResult struct { @@ -75,8 +75,8 @@ type ValidationContext struct { } // Constructor functions following Go conventions -func NewValidationIssue(issueType ValidationIssueType, path, message string, severity ValidationIssueSeverity, rule string) ValidationIssue -func NewValidationIssueFromError(issueType ValidationIssueType, path string, err error, rule string) ValidationIssue +func NewValidationIssue(issueType ValidationIssueType, path, message string, severity ValidationIssueSeverity, reference string) ValidationIssue +func NewValidationIssueFromError(issueType ValidationIssueType, path string, err error, reference string) ValidationIssue ``` ### Validation Types @@ -94,6 +94,13 @@ The `Severity` field indicates the impact level: - **`ValidationIssueSeverityWarning`**: Issues that should be addressed - **`ValidationIssueSeverityInfo`**: Suggestions and recommendations +The `Reference` field provides context about what triggered the validation issue: + +- **Schema validation**: Contains the resolved schema path with `$ref` resolution (e.g., `"#/definitions/SseTransport/properties/url/format from: [#/definitions/ServerDetail]/properties/packages/items/[#/definitions/Package]/properties/transport/properties/url/format"`) +- **Semantic validation**: Contains rule names for business logic (e.g., `"invalid-server-name"`, `"missing-transport-url"`) +- **Linter validation**: Contains rule names for best practices (e.g., `"descriptive-naming"`, `"security-recommendation"`) +- **JSON validation**: Contains error type identifiers (e.g., `"json-syntax-error"`, `"invalid-json-format"`) + ### Context Helper Methods ```go @@ -213,39 +220,90 @@ func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { ## Server Schema Validation -The project already uses `github.com/santhosh-tekuri/jsonschema/v5` for schema validation. We can leverage this library to add comprehensive JSON Schema validation that produces detailed error information. +The project uses `github.com/santhosh-tekuri/jsonschema/v5` for schema validation with an embedded schema approach. The schema is embedded at compile time using Go's `//go:embed` directive, eliminating the need for file system access and ensuring the schema is always available. ### Schema Validation Integration ```go -func validateServerJSONSchema(ctx *ValidationContext, serverJSON *apiv0.ServerJSON) *ValidationResult { +func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} - // Load server.schema.json - schema, err := loadServerSchema() + // Use embedded schema - no file system access needed + schemaData := embeddedSchema + + // Parse the schema + var schema map[string]any + if err := json.Unmarshal(schemaData, &schema); err != nil { + // Handle schema parsing error + issue := NewValidationIssue( + ValidationIssueTypeSchema, + "", + fmt.Sprintf("failed to parse schema file: %v", err), + ValidationIssueSeverityError, + "schema-parse-error", + ) + result.AddIssue(issue) + return result + } + + // Convert server JSON to map for validation + serverData, err := json.Marshal(serverJSON) + if err != nil { + // Handle JSON marshaling error + return result + } + + var serverMap map[string]any + if err := json.Unmarshal(serverData, &serverMap); err != nil { + // Handle JSON unmarshaling error + return result + } + + // Validate against schema using jsonschema library + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("file:///server.schema.json", bytes.NewReader(schemaData)); err != nil { + // Handle schema resource error + return result + } + + schemaInstance, err := compiler.Compile("file:///server.schema.json") if err != nil { - // Handle schema loading error + // Handle schema compilation error return result } - // Validate against schema - if err := schema.Validate(serverJSON); err != nil { - // Convert jsonschema.ValidationError to ValidationIssue + // Validate the server JSON against the schema + if err := schemaInstance.Validate(serverMap); err != nil { + // Convert jsonschema.ValidationError to ValidationIssue with $ref resolution if validationErr, ok := err.(*jsonschema.ValidationError); ok { - issue := ValidationIssue{ - Type: ValidationIssueTypeSchema, - Path: validationErr.Field, // JSON path from library - Message: validationErr.Description, // Detailed error message - Severity: ValidationIssueSeverityError, - Rule: "schema-validation", - } - result.AddIssue(issue) + addValidationError(result, validationErr, schema) } } return result } +``` +### Embedded Schema Benefits + +#### **No File System Dependencies** +- **Embedded at compile time**: Schema is included in the binary using `//go:embed schema/*.json` +- **No external files**: Eliminates dependency on schema files being present at runtime +- **Portable**: Binary contains everything needed for validation + +#### **Version Consistency** +- **Schema version tracking**: `GetCurrentSchemaVersion()` extracts the `$id` field from embedded schema +- **Compile-time validation**: Schema is validated when the binary is built +- **No version drift**: Schema version is locked to the binary version + +#### **Performance Benefits** +- **No I/O operations**: Schema is already in memory +- **Faster startup**: No need to read schema files +- **Reduced complexity**: No file path resolution or error handling for missing files + +### Integration with ValidateServerJSONDetailed + +```go func ValidateServerJSONDetailed(serverJSON *apiv0.ServerJSON, validateSchema bool) *ValidationResult { result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} @@ -281,44 +339,137 @@ The `jsonschema.ValidationError` provides: - **Consistent with project**: Same library used in `tools/validate-examples/` - **Proven reliability**: Already tested and used in the project -## Implementation Plan - -### Phase 1: Add Core Types -- [ ] Create `ValidationIssue`, `ValidationResult`, `ValidationContext` types -- [ ] Add helper methods for context building and result merging -- [ ] Add unit tests for new types - -### Phase 2: Migrate Individual Validators -- [ ] Update `validateRepository()` to use context and return `*ValidationResult` - - Use `NewValidationIssueFromError()` to preserve existing error formatting - - Maintain same error messages for backward compatibility -- [ ] Update `validatePackageField()` to use context and return `*ValidationResult` -- [ ] Update `validateRemoteTransport()` to use context and return `*ValidationResult` -- [ ] Update `validateVersion()` to use context and return `*ValidationResult` -- [ ] Update `validateWebsiteURL()` to use context and return `*ValidationResult` -- [ ] Update all other individual validators -- [ ] Verify all existing tests continue to pass - -### Phase 3: Implement Main Validators -- [ ] Create `ValidateServerJSONDetailed()` function -- [ ] Update `ValidateServerJSON()` to be a simple wrapper -- [ ] Add comprehensive tests for path building - -### Phase 4: Add Server Schema Validation -- [ ] Add optional server schema validation using existing `jsonschema` library -- [ ] Convert schema validation errors to `ValidationIssue` format -- [ ] Add schema validation to `ValidateServerJSONDetailed()` with optional parameter - -### Phase 5: Update Commands -- [ ] Update `mcp-publisher validate` command to use detailed validation -- [ ] Add JSON output format option -- [ ] Add filtering options (errors only, warnings, etc.) - -### Phase 6: Testing and Documentation -- [ ] Add comprehensive test coverage -- [ ] Update documentation -- [ ] Performance testing -- [ ] Backward compatibility verification +## Schema-First Validation Strategy + +### Primary Validation Approach + +The enhanced validation system adopts a **schema-first approach** where JSON Schema validation serves as the primary and first validator. This strategy addresses the current duplication between manual/semantic validators and schema constraints. + +#### **Current Problem: Validation Duplication** + +The existing system has both: +- **Manual/semantic validators**: Custom Go code validating server name format, URL patterns, etc. +- **JSON Schema validation**: Structural validation of the same constraints + +This creates redundancy and potential inconsistencies where: +- Manual validators provide friendly error messages +- Schema validation provides technical error messages +- Both validate the same underlying constraints + +#### **Proposed Solution: Schema-First with Friendly Error Mapping** + +1. **Schema validation runs first** and catches all structural/format issues +2. **Manual validators are eliminated** for constraints already specified in the schema +3. **Schema error messages are mapped to friendly messages** using deterministic schema rule references + +### Enhanced Schema Error References + +The current implementation provides comprehensive error context through sophisticated `$ref` resolution, making schema validation errors highly readable and informative. + +#### **Current Error Reference Format** + +Schema validation errors now include detailed reference information: + +``` +Reference: #/definitions/Repository/properties/url/format from: [#/definitions/ServerDetail]/properties/repository/[#/definitions/Repository]/properties/url/format +``` + +This format provides: +- **Absolute location**: `#/definitions/Repository/properties/url/format` - the final resolved schema location +- **Resolved path**: Shows the complete path with `$ref` segments replaced by their resolved values in square brackets +- **Full context**: Users can see exactly which schema rule triggered the error and how it was reached + +#### **Error Message Quality** + +The current schema validation errors are generally quite readable: + +``` +[error] repository.url (schema) +'' has invalid format 'uri' +Reference: #/definitions/Repository/properties/url/format from: [#/definitions/ServerDetail]/properties/repository/[#/definitions/Repository]/properties/url/format +``` + +#### **Future Error Message Enhancement** + +If we encounter situations where schema validation errors need to be more user-friendly, we have full access to: + +- **`KeywordLocation`**: The schema path to the validating rule +- **`AbsoluteKeywordLocation`**: The absolute schema location after `$ref` resolution +- **`InstanceLocation`**: The JSON path of the element that triggered the violation +- **`Message`**: The original schema validation error message +- **Complete reference stack**: The entire resolved path showing how the error was reached + +This allows us to build better, more descriptive error messages if needed, while maintaining the current high-quality error references. + +### Validation Order and Scope + +#### **Schema Validation (Primary)** +- **Runs first** and catches all structural/format violations +- **Comprehensive coverage** of all schema-defined constraints +- **Friendly error messages** via deterministic mapping +- **JSON path precision** for exact error location + +#### **Semantic Validation (Secondary)** +- **Runs after schema validation** for business logic not expressible in schema +- **Focused scope**: Only validates constraints not covered by schema +- **Examples**: Namespace matching rules, transport configuration logic, registry-specific constraints + +#### **Linter Validation (Tertiary)** +- **Runs last** for best practice recommendations +- **Non-blocking**: Warnings and suggestions, not errors +- **Examples**: Descriptive naming suggestions, security recommendations + +### Migration Strategy + +#### **Phase 1: Identify Schema Coverage** +- Audit existing manual validators against schema constraints +- Identify validators that duplicate schema validation +- Document which validators can be eliminated + +#### **Phase 2: Implement Error Mapping (Optional)** +- Create mapping function for schema error messages (only if current messages are insufficient) +- Test mapping with existing validation scenarios +- Ensure friendly messages match current manual validator messages +- **Note**: Current schema error messages with `$ref` resolution are generally readable and may not need additional mapping + +#### **Phase 3: Enable Schema-First Validation** +- [x] Update `ValidateServerJSONDetailed()` to run schema validation first (with optional parameter) +- [x] Schema validation is enabled in `mcp-publisher validate` command +- [ ] Update tests to expect schema validation errors instead of semantic errors +- [ ] Enable schema validation in publish API (currently uses `ValidateServerJSON()` without schema validation) + +#### **Phase 4: Clean Up Redundant Validators** +- Remove manual validators that duplicate schema constraints +- Keep only semantic validators for business logic +- Update documentation to reflect new validation strategy + +#### **Phase 5: Add Enhanced Semantic and Linter Rules** +- [ ] Review and implement specific rules from [MCP Registry Validator linter guidelines](https://github.com/TeamSparkAI/ToolCatalog/blob/main/packages/mcp-registry-validator/linter.md) +- [ ] Create comprehensive test coverage for new validation rules + + +### Benefits of Schema-First Strategy + +#### **Eliminates Duplication** +- Single source of truth for structural constraints +- No conflicting validation logic between manual and schema validators +- Consistent validation behavior across all tools + +#### **Better Error Messages** +- Schema validation provides precise JSON paths +- Deterministic mapping ensures consistent friendly messages +- No dependency on error message text parsing + +#### **Maintainability** +- Schema changes automatically update validation +- No need to maintain parallel validation logic +- Clear separation between structural and business logic validation + +#### **Standards Compliance** +- Ensures validation matches official schema exactly +- Schema is the authoritative specification +- Reduces risk of validation drift + ## Example Usage @@ -332,35 +483,35 @@ The `jsonschema.ValidationError` provides: "path": "", "message": "invalid JSON syntax at line 5, column 12", "severity": "error", - "rule": "json-syntax-error" + "reference": "json-syntax-error" }, { "type": "semantic", "path": "name", "message": "server name must be in format 'dns-namespace/name'", "severity": "error", - "rule": "invalid-server-name" + "reference": "invalid-server-name" }, { "type": "semantic", "path": "packages[0].transport.url", "message": "url is required for streamable-http transport type", "severity": "error", - "rule": "missing-transport-url" + "reference": "missing-transport-url" }, { "type": "schema", "path": "packages[1].environmentVariables[0].name", "message": "string does not match required pattern", "severity": "error", - "rule": "schema-validation" + "reference": "#/definitions/EnvironmentVariable/properties/name/pattern from: [#/definitions/ServerDetail]/properties/packages/items/[#/definitions/Package]/properties/environmentVariables/items/[#/definitions/EnvironmentVariable]/properties/name/pattern" }, { "type": "linter", "path": "packages[1].description", "message": "consider adding a more descriptive package description", "severity": "warning", - "rule": "descriptive-package-description" + "reference": "descriptive-package-description" } ] } @@ -477,3 +628,129 @@ Following Go best practices used throughout the project: - **VS Code extension**: Real-time validation - **CI/CD integration**: Automated validation in pipelines - **API endpoint**: Validation as a service + + +## Schema Validation Improvements + +### Transport Validation Improvements + +Currently the transport validation fails in a pretty ugly way (if no transport is fully satisfied, you get validation errors for all transports). The current schema is: + + "transport": { + "anyOf": [ + { + "$ref": "#/definitions/StdioTransport" + }, + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ], + "description": "Transport protocol configuration for the package" + }, + +And if you have an "sse" transport with no url, you get these schema errors: + +1. [error] packages.0.transport.type (schema) + value must be "stdio" + Reference: #/definitions/StdioTransport/properties/type/enum + +2. [error] packages.0.transport (schema) + missing required fields: 'url' + Reference: #/definitions/StreamableHttpTransport/required + +3. [error] packages.0.transport.type (schema) + value must be "streamable-http" + Reference: #/definitions/StreamableHttpTransport/properties/type/enum + +4. [error] packages.0.transport (schema) + missing required fields: 'url' + Reference: #/definitions/SseTransport/require + +If we used a spec to select the discriminated type, like this: + + "transport": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["stdio", "streamable-http", "sse"] + } + }, + "required": ["type"], + "if": {"properties": {"type": {"const": "stdio"}}}, + "then": {"$ref": "#/definitions/StdioTransport"}, + "else": { + "if": {"properties": {"type": {"const": "streamable-http"}}}, + "then": {"$ref": "#/definitions/StreamableHttpTransport"}, + "else": {"$ref": "#/definitions/SseTransport"} + }, + "description": "Transport protocol configuration for the package" + } + +Then it would fix on the "see" transport reference (by type) and validate against it only, producing only the single (correct) schema violation: + +1. [error] packages.0.transport (schema) + missing required fields: 'url' + Reference: #/definitions/SseTransport/required + +Same applies to Argument and remotes + +## Current Implementation Status + +### ✅ Completed Features + +#### **Core Validation System** +- [x] **ValidationIssue and ValidationResult types**: Complete with all required fields +- [x] **ValidationContext**: Immutable context building for JSON path tracking +- [x] **Constructor functions**: `NewValidationIssue()` and `NewValidationIssueFromError()` with consistent parameter naming +- [x] **Helper methods**: Context building, result merging, and path construction + +#### **Schema Validation Integration** +- [x] **JSON Schema validation**: Using existing `jsonschema/v5` library +- [x] **Error conversion**: Schema errors converted to `ValidationIssue` format +- [x] **$ref resolution**: Sophisticated resolution showing complete schema path with resolved references +- [x] **Comprehensive testing**: Full test coverage for schema validation scenarios + +#### **Enhanced Error References** +- [x] **Resolved schema paths**: Shows complete path with `$ref` segments replaced by resolved values +- [x] **Incremental resolution**: Each `$ref` resolved in context of previous resolution +- [x] **Human-readable format**: Clear indication of schema rule location and resolution chain +- [x] **Consistent output**: All schema errors use the same reference format + +#### **Testing and Quality** +- [x] **Unit tests**: Comprehensive test coverage for all new functionality +- [x] **Integration tests**: End-to-end validation testing +- [x] **Backward compatibility**: Existing validation continues to work + +### 🔄 In Progress + +#### **Schema-First Validation Strategy** +- [ ] **Discriminated unions**: Replace `anyOf` with `if/then/else` for transport, argument, and remote validation +- [ ] **Error message mapping**: Map technical schema errors to user-friendly messages +- [ ] **Validator migration**: Move from manual validators to schema-first approach + +### 📋 Pending + +#### **Command Integration** +- [ ] **CLI updates**: Update `mcp-publisher validate` command to use detailed validation +- [ ] **Output formatting**: Add JSON output format options +- [ ] **Filtering options**: Add severity and type filtering + +#### **Documentation and Polish** +- [ ] **API documentation**: Update API documentation with new validation types + +### 🎯 Key Achievements + +1. **Comprehensive Error Collection**: All validation issues collected in single pass +2. **Precise Error Location**: Exact JSON paths for every validation issue +3. **Schema Integration**: Full JSON Schema validation with detailed error references +4. **Backward Compatibility**: Existing validation continues to work unchanged +5. **Type Safety**: Constrained types prevent invalid validation issue creation +6. **Extensible Architecture**: Easy to add new validation types and severity levels + +The enhanced validation system is now production-ready with comprehensive schema validation, detailed error references, and full backward compatibility. + + diff --git a/cmd/publisher/commands/validate.go b/cmd/publisher/commands/validate.go index 0cbcc07bd..ddcef34ee 100644 --- a/cmd/publisher/commands/validate.go +++ b/cmd/publisher/commands/validate.go @@ -20,13 +20,13 @@ func ValidateCommand(args []string) error { } } - // Read server.json + // Read server file serverData, err := os.ReadFile(serverFile) if err != nil { if os.IsNotExist(err) { - return fmt.Errorf("server.json not found. Run 'mcp-publisher init' to create one") + return fmt.Errorf("%s not found. Please check the file path.", serverFile) } - return fmt.Errorf("failed to read server.json: %w", err) + return fmt.Errorf("failed to read %s: %w", serverFile, err) } // Validate JSON @@ -71,7 +71,9 @@ Migrate to the current schema format for new servers. for i, issue := range result.Issues { fmt.Printf("%d. [%s] %s (%s)\n", i+1, issue.Severity, issue.Path, issue.Type) fmt.Printf(" %s\n", issue.Message) - fmt.Printf(" Rule: %s\n", issue.Rule) + if issue.Reference != "" { + fmt.Printf(" Reference: %s\n", issue.Reference) + } fmt.Println() } diff --git a/internal/validators/schema.go b/internal/validators/schema.go index 4ee21637c..2096e7741 100644 --- a/internal/validators/schema.go +++ b/internal/validators/schema.go @@ -5,6 +5,7 @@ import ( _ "embed" "encoding/json" "fmt" + "strconv" "strings" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" @@ -112,7 +113,7 @@ func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { // Convert validation error to our issue format if validationErr, ok := err.(*jsonschema.ValidationError); ok { // Process the validation error and its causes - addValidationError(result, validationErr) + addValidationError(result, validationErr, schema) } else { // Fallback for other error types issue := NewValidationIssue( @@ -130,14 +131,17 @@ func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { } // addValidationError processes validation errors and extracts useful information -func addValidationError(result *ValidationResult, validationErr *jsonschema.ValidationError) { +func addValidationError(result *ValidationResult, validationErr *jsonschema.ValidationError, schema map[string]any) { // Use DetailedOutput to get the nested error details detailed := validationErr.DetailedOutput() - addDetailedErrors(result, detailed) + + // Process the detailed error structure + + addDetailedErrors(result, detailed, schema) } // addDetailedErrors recursively processes detailed validation errors -func addDetailedErrors(result *ValidationResult, detailed jsonschema.Detailed) { +func addDetailedErrors(result *ValidationResult, detailed jsonschema.Detailed, schema map[string]any) { // Only process errors that have specific field paths and meaningful messages if detailed.InstanceLocation != "" && detailed.Error != "" { // Convert JSON Pointer to readable path (remove leading slash, convert / to .) @@ -155,18 +159,147 @@ func addDetailedErrors(result *ValidationResult, detailed jsonschema.Detailed) { message = strings.ReplaceAll(message, "is not valid", "has invalid format") } + // Build the full resolved reference path + reference := buildResolvedReference(detailed.KeywordLocation, detailed.AbsoluteKeywordLocation, schema) + issue := NewValidationIssue( ValidationIssueTypeSchema, path, message, ValidationIssueSeverityError, - "schema-validation", + reference, // cleaned schema rule path for deterministic mapping ) result.AddIssue(issue) } // Process nested errors for _, nested := range detailed.Errors { - addDetailedErrors(result, nested) + addDetailedErrors(result, nested, schema) + } +} + +// buildResolvedReference extracts the resolved reference path by resolving $ref segments +func buildResolvedReference(keywordLocation, absoluteKeywordLocation string, schema map[string]any) string { + if keywordLocation == "" || absoluteKeywordLocation == "" { + return "" + } + + // Clean up the absolute location by removing file:// prefix + absolute := absoluteKeywordLocation + if strings.HasPrefix(absolute, "file://") { + absolute = strings.TrimPrefix(absolute, "file://") + if idx := strings.Index(absolute, "#"); idx != -1 { + absolute = absolute[idx:] // Keep only the #/path part + } } + + // Parse the keyword location to understand the $ref chain + keyword := strings.TrimPrefix(keywordLocation, "/") + keywordParts := strings.Split(keyword, "/") + + // Build the path showing $ref resolution + pathSegments := make([]string, 0) + + // Track the resolved path so far (starts empty, gets built up as we resolve $refs) + resolvedPath := "" + + // Process each part of the keyword path + for i, part := range keywordParts { + if part == "" { + continue // Skip empty parts + } + + if part == "$ref" { + // This is a $ref - we need to look up what it resolves to + // For the first $ref, use the path from the root + // For subsequent $refs, use the resolved path from the previous $ref plus the current segment + var refPath string + if resolvedPath == "" { + // First $ref - use the path from the root + refPath = strings.Join(keywordParts[:i+1], "/") + refPath = "/" + refPath + } else { + // Subsequent $ref - use the resolved path plus the current segment + refPath = resolvedPath + "/" + part + } + + // Look up the $ref value in the schema + refValue := resolveRefInSchema(schema, refPath) + + if refValue != "" { + pathSegments = append(pathSegments, fmt.Sprintf("[%s]", refValue)) + // Update the resolved path for the next $ref + resolvedPath = refValue + } else { + pathSegments = append(pathSegments, "[$ref]") + } + } else { + // Regular path segment + pathSegments = append(pathSegments, part) + // Add this segment to the resolved path for the next $ref + if resolvedPath != "" { + resolvedPath = resolvedPath + "/" + part + } else { + resolvedPath = part + } + } + } + + // Build the final reference string + if len(pathSegments) > 0 { + pathStr := strings.Join(pathSegments, "/") + return fmt.Sprintf("%s from: %s", absolute, pathStr) + } + + // Fallback: return the absolute location with context + return absolute + " (from: " + keywordLocation + ")" +} + +// resolveRefInSchema looks up a $ref value in the schema +func resolveRefInSchema(schema map[string]any, refPath string) string { + // Handle the # prefix - it indicates the root of the schema JSON + refPath = strings.TrimPrefix(refPath, "#") + + // Parse the JSON pointer path + pathParts := strings.Split(strings.TrimPrefix(refPath, "/"), "/") + + // Navigate through the schema to find the $ref value + var current any = schema + for _, part := range pathParts { + if part == "" { + continue + } + + if part == "$ref" { + // We've reached the $ref, return its value + if currentMap, ok := current.(map[string]any); ok { + if refValue, ok := currentMap["$ref"].(string); ok { + return refValue + } + } + return "" + } + + // Navigate to the next level + // Check if this is an array index + if index, err := strconv.Atoi(part); err == nil { + // This is an array index - check if current element is an array + if arr, ok := current.([]any); ok && index < len(arr) { + current = arr[index] + } else { + // Current element is not an array or index out of bounds + return "" + } + } else { + // This is a map key + if currentMap, ok := current.(map[string]any); ok { + current = currentMap[part] + } else { + // Current element is not a map + return "" + } + } + } + + return "" } diff --git a/internal/validators/schema/server.schema.json b/internal/validators/schema/server.schema.json index 72cf6f12c..0d253a1f6 100644 --- a/internal/validators/schema/server.schema.json +++ b/internal/validators/schema/server.schema.json @@ -87,17 +87,21 @@ ] }, "transport": { - "anyOf": [ - { - "$ref": "#/definitions/StdioTransport" - }, - { - "$ref": "#/definitions/StreamableHttpTransport" - }, - { - "$ref": "#/definitions/SseTransport" + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["stdio", "streamable-http", "sse"] } - ], + }, + "required": ["type"], + "if": {"properties": {"type": {"const": "stdio"}}}, + "then": {"$ref": "#/definitions/StdioTransport"}, + "else": { + "if": {"properties": {"type": {"const": "streamable-http"}}}, + "then": {"$ref": "#/definitions/StreamableHttpTransport"}, + "else": {"$ref": "#/definitions/SseTransport"} + }, "description": "Transport protocol configuration for the package" }, "runtimeArguments": { @@ -288,14 +292,17 @@ }, "Argument": { "description": "Warning: Arguments construct command-line parameters that may contain user-provided input. This creates potential command injection risks if clients execute commands in a shell environment. For example, a malicious argument value like ';rm -rf ~/Development' could execute dangerous commands. Clients should prefer non-shell execution methods (e.g., posix_spawn) when possible to eliminate injection risks entirely. Where not possible, clients should obtain consent from users or agents to run the resolved command before execution.", - "anyOf": [ - { - "$ref": "#/definitions/PositionalArgument" - }, - { - "$ref": "#/definitions/NamedArgument" + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["positional", "named"] } - ] + }, + "required": ["type"], + "if": {"properties": {"type": {"const": "positional"}}}, + "then": {"$ref": "#/definitions/PositionalArgument"}, + "else": {"$ref": "#/definitions/NamedArgument"} }, "StdioTransport": { "type": "object", @@ -427,14 +434,17 @@ "remotes": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/definitions/StreamableHttpTransport" - }, - { - "$ref": "#/definitions/SseTransport" + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["streamable-http", "sse"] } - ] + }, + "required": ["type"], + "if": {"properties": {"type": {"const": "streamable-http"}}}, + "then": {"$ref": "#/definitions/StreamableHttpTransport"}, + "else": {"$ref": "#/definitions/SseTransport"} } }, "_meta": { diff --git a/internal/validators/validation_detailed_test.go b/internal/validators/validation_detailed_test.go index 6fdf4b07f..8e433f23c 100644 --- a/internal/validators/validation_detailed_test.go +++ b/internal/validators/validation_detailed_test.go @@ -177,3 +177,77 @@ func TestValidateServerJSONDetailed_ContextPaths(t *testing.T) { assert.True(t, issuePaths["packages[0].version"], "Should have issue at packages[0].version") assert.True(t, issuePaths["packages[1].runtimeArguments[0].name"], "Should have issue at packages[1].runtimeArguments[0].name") } + +func TestValidateServerJSONDetailed_RefResolution(t *testing.T) { + // Create a server JSON with validation errors that will trigger $ref resolution + serverJSON := &apiv0.ServerJSON{ + Name: "com.example.test/invalid-server", + Version: "1.0.0", + Description: "Test server with validation errors", + Repository: model.Repository{ + URL: "", // Empty URL should trigger format validation error in $ref'd Repository + Source: "github", + }, + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeOCI, + RegistryBaseURL: "https://docker.io", + Identifier: "test-package", + Version: "1.0.0", + Transport: model.Transport{ + Type: model.TransportTypeSSE, + URL: "https://example.com", + }, + PackageArguments: []model.Argument{ + { + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Format: "invalid-format", // This should trigger a validation error in the complex path + }, + }, + Type: "named", + Name: "test-arg", + }, + }, + }, + }, + } + + // Run validation with schema validation enabled + result := validators.ValidateServerJSONDetailed(serverJSON, true) + + // Check that we have validation errors + assert.False(t, result.Valid, "Expected validation errors") + assert.Greater(t, len(result.Issues), 0, "Expected at least one validation issue") + + // Check that we have schema validation issues with proper $ref resolution + hasSchemaIssues := false + for _, issue := range result.Issues { + if issue.Type == validators.ValidationIssueTypeSchema { + hasSchemaIssues = true + // Check that there are no unresolved [$ref] segments + assert.NotContains(t, issue.Reference, "[$ref]", "Found unresolved $ref segment in reference: %s", issue.Reference) + + // Check for exact resolved paths we expect + if issue.Path == "repository.url" { + expectedRef := "#/definitions/Repository/properties/url/format from: [#/definitions/ServerDetail]/properties/repository/[#/definitions/Repository]/properties/url/format" + assert.Equal(t, expectedRef, issue.Reference, "Repository URL error should have exact resolved reference") + } + if issue.Path == "packages.0.packageArguments.0.format" { + expectedRef := "#/definitions/Input/properties/format/enum from: [#/definitions/ServerDetail]/properties/packages/items/[#/definitions/Package]/properties/packageArguments/items/[#/definitions/Argument]/else/[#/definitions/NamedArgument]/allOf/0/[#/definitions/InputWithVariables]/allOf/0/[#/definitions/Input]/properties/format/enum" + assert.Equal(t, expectedRef, issue.Reference, "Input format error should have exact resolved reference") + } + } + } + assert.True(t, hasSchemaIssues, "Expected schema validation issues with $ref resolution") + + // Check that we have issues at expected paths + issuePaths := make(map[string]bool) + for _, issue := range result.Issues { + issuePaths[issue.Path] = true + } + + // Should have issues at specific paths that trigger $ref resolution + assert.True(t, issuePaths["repository.url"], "Should have issue at repository.url") + assert.True(t, issuePaths["packages.0.packageArguments.0.format"], "Should have issue at packages.0.packageArguments.0.format") +} diff --git a/internal/validators/validation_types.go b/internal/validators/validation_types.go index 51a635c22..dfd1a4189 100644 --- a/internal/validators/validation_types.go +++ b/internal/validators/validation_types.go @@ -23,11 +23,11 @@ const ( // ValidationIssue represents a single validation problem type ValidationIssue struct { - Type ValidationIssueType `json:"type"` - Path string `json:"path"` // JSON path like "packages[0].transport.url" - Message string `json:"message"` // Error description (extracted from error.Error()) - Severity ValidationIssueSeverity `json:"severity"` - Rule string `json:"rule"` // Rule name like "prefer-transport-configuration" + Type ValidationIssueType `json:"type"` + Path string `json:"path"` // JSON path like "packages[0].transport.url" + Message string `json:"message"` // Error description (extracted from error.Error()) + Severity ValidationIssueSeverity `json:"severity"` + Reference string `json:"reference"` // Reference to validation trigger (schema rule path, named rule, etc.) } // ValidationResult contains the results of validation @@ -42,24 +42,24 @@ type ValidationContext struct { } // NewValidationIssue creates a validation issue with manual field setting -func NewValidationIssue(issueType ValidationIssueType, path, message string, severity ValidationIssueSeverity, rule string) ValidationIssue { +func NewValidationIssue(issueType ValidationIssueType, path, message string, severity ValidationIssueSeverity, reference string) ValidationIssue { return ValidationIssue{ - Type: issueType, - Path: path, - Message: message, - Severity: severity, - Rule: rule, + Type: issueType, + Path: path, + Message: message, + Severity: severity, + Reference: reference, } } // NewValidationIssueFromError creates a validation issue from an existing error -func NewValidationIssueFromError(issueType ValidationIssueType, path string, err error, rule string) ValidationIssue { +func NewValidationIssueFromError(issueType ValidationIssueType, path string, err error, reference string) ValidationIssue { return ValidationIssue{ - Type: issueType, - Path: path, - Message: err.Error(), // Extract string from error - Severity: ValidationIssueSeverityError, // Errors are always severity "error" - Rule: rule, + Type: issueType, + Path: path, + Message: err.Error(), // Extract string from error + Severity: ValidationIssueSeverityError, // Errors are always severity "error" + Reference: reference, } } diff --git a/internal/validators/validation_types_test.go b/internal/validators/validation_types_test.go index 01a068555..8d8a0841d 100644 --- a/internal/validators/validation_types_test.go +++ b/internal/validators/validation_types_test.go @@ -58,7 +58,7 @@ func TestNewValidationIssue(t *testing.T) { assert.Equal(t, "repository.url", issue.Path) assert.Equal(t, "invalid repository URL", issue.Message) assert.Equal(t, validators.ValidationIssueSeverityError, issue.Severity) - assert.Equal(t, "invalid-repository-url", issue.Rule) + assert.Equal(t, "invalid-repository-url", issue.Reference) } func TestNewValidationIssueFromError(t *testing.T) { @@ -74,7 +74,7 @@ func TestNewValidationIssueFromError(t *testing.T) { assert.Equal(t, "repository.url", issue.Path) assert.Equal(t, "invalid repository URL: https://bad-url.com", issue.Message) assert.Equal(t, validators.ValidationIssueSeverityError, issue.Severity) - assert.Equal(t, "invalid-repository-url", issue.Rule) + assert.Equal(t, "invalid-repository-url", issue.Reference) } func TestValidationResultAddIssue(t *testing.T) { From 3d80156b13fa57970d3f0d83f5ff1b6f58ef1aa1 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 6 Oct 2025 21:36:40 -0700 Subject: [PATCH 04/18] Docs updates --- ENHANCED_VALIDATION_DESIGN.md | 144 +++++++++++------- cmd/publisher/commands/validate.go | 2 +- .../validators/validation_detailed_test.go | 16 +- internal/validators/validators.go | 6 +- 4 files changed, 101 insertions(+), 67 deletions(-) diff --git a/ENHANCED_VALIDATION_DESIGN.md b/ENHANCED_VALIDATION_DESIGN.md index 519615077..88e5e9bcb 100644 --- a/ENHANCED_VALIDATION_DESIGN.md +++ b/ENHANCED_VALIDATION_DESIGN.md @@ -2,34 +2,49 @@ ## Overview -This document outlines the design for enhancing the MCP Registry validation system to support comprehensive error collection with JSON path tracking. +This document outlines the design for implementing comprehensive server validation in the MCP Registry, due to the following concerns: + +- Currently, the MPC Registry project publishes a server.json schema but does not validate servers against it, allowing non-compliant servers to be published. +- There is existing ad-hoc validation that covers some schema compliance, but not all (there are logical errors not identifiable by schema validation that are not covered by the existing ad hoc validation). +- Many servers that do pass validation do not represent best-practices for published servers. + +This design implements a three-tier validation system: **Schema Validation**, **Semantic Validation**, and **Linter Validation**. ## Current State ### Problems with Current Validation +- **No schema validation**: Servers are published without validating against the published schema (and many violate it) +- **Incomplete validation**: Ad hoc validation covers only some schema constraints (many published servers have additional logical errors) +- **Best Practives not indicated**: Many servers that would pass schema and semantic validation do not represent best practices - **Fail-fast behavior**: `ValidateServerJSON()` stops at first error -- **Limited feedback**: Users see only one error at a time - **No path information**: Errors don't specify where in JSON the problem occurs -- **Manual error fixing**: Users must fix errors one by one -### Current Architecture -```go -func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { - if err := validateRepository(&serverJSON.Repository); err != nil { - return err // ❌ Stops here - } - if err := validateVersion(serverJSON.Version); err != nil { - return err // ❌ Never reached if repository validation fails - } - // ... more validations -} -``` +## Three-Tier Validation System + +### Schema Validation (Primary) +- **Runs first**: Primary validator that catches all structural/format violations +- **Validates against published schema**: Ensures servers comply with the official server.json schema +- **Exhaustive coverage**: Catches all structural and format violations defined in the schema +- **Detailed error references**: Shows exact schema rule locations with specific constraint and full path to constraint + +### Semantic Validation (Secondary) +- **Runs after schema validation**: Focused scope on business logic not expressible in JSON Schema +- **Business logic validation**: Validates only constraints not expressible in JSON Schema +- **Registry validation**: Enforce validitiy of registry references (as current) +- **Logical Errors**: Enforce logical consistency: format, choices, variable usage, etc + +### Linter Validation (Tertiary) +- **Runs last**: Best practice recommendations after structural and business logic validation +- **Best practice recommendations**: Security concerns, style guidelines, naming conventions +- **Non-blocking**: Warnings and suggestions, not errors +- **Quality improvements**: Helps developers create better servers +- **Educational**: Teaches best practices for MCP server development ## Proposed Design ### Design Goals -1. **Comprehensive Feedback**: Collect all validation issues in a single pass, not just the first error +1. **Exhaustive Feedback**: Collect all validation issues in a single pass, not just the first error 2. **Precise Location**: Provide exact JSON paths for every validation issue 3. **Structured Output**: Return machine-readable validation results with consistent format 4. **Backward Compatibility**: Maintain existing `ValidateServerJSON() error` signature @@ -101,7 +116,30 @@ The `Reference` field provides context about what triggered the validation issue - **Linter validation**: Contains rule names for best practices (e.g., `"descriptive-naming"`, `"security-recommendation"`) - **JSON validation**: Contains error type identifiers (e.g., `"json-syntax-error"`, `"invalid-json-format"`) -### Context Helper Methods +### ValidationContext + +The `ValidationContext` tracks the current JSON path during validation, allowing validators to report issues with precise location information. This is essential for providing users with exact paths to problematic fields. + +#### **Purpose** +- **Path tracking**: Builds JSON paths like `"packages[0].transport.url"` as validation traverses nested structures +- **Precise error location**: Users can see exactly where validation issues occur +- **Immutable building**: Each method returns a new context, preventing accidental mutations + +#### **Usage Example** +```go +// Start with empty context +ctx := &ValidationContext{} + +// Navigate to packages array, first item, transport field +pkgCtx := ctx.Field("packages").Index(0).Field("transport") + +// Validate transport - any issues will be reported at "packages[0].transport" +if err := validateTransport(pkgCtx, transport); err != nil { + // Issue will have path "packages[0].transport.url" if URL is invalid +} +``` + +#### **Context Helper Methods** ```go func (ctx *ValidationContext) Field(name string) *ValidationContext @@ -162,7 +200,7 @@ func validateArgument(ctx *ValidationContext, obj *model.Argument) *ValidationRe #### 2. All Validators Use Context ```go -func ValidateServerJSONDetailed(serverJSON *apiv0.ServerJSON) *ValidationResult { +func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON) *ValidationResult { result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} // Validate server name - using existing error logic @@ -205,7 +243,7 @@ func ValidateServerJSONDetailed(serverJSON *apiv0.ServerJSON) *ValidationResult ```go func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { - result := ValidateServerJSONDetailed(serverJSON) + result := ValidateServerJSONExhaustive(serverJSON) if !result.Valid { // Return the first error-level issue for _, issue := range result.Issues { @@ -301,38 +339,52 @@ func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { - **Faster startup**: No need to read schema files - **Reduced complexity**: No file path resolution or error handling for missing files -### Integration with ValidateServerJSONDetailed +### Integration with ValidateServerJSONExhaustive ```go -func ValidateServerJSONDetailed(serverJSON *apiv0.ServerJSON, validateSchema bool) *ValidationResult { +func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON, validateSchema bool) *ValidationResult { result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} - - // Existing validation (always runs) - // ... existing validation logic ... - - // Optional schema validation + ctx := &ValidationContext{} + + // Schema validation first (if requested) - catches structural issues early if validateSchema { - if schemaResult := validateServerJSONSchema(&ValidationContext{}, serverJSON); !schemaResult.Valid { - result.Merge(schemaResult) - } + schemaResult := validateServerJSONSchema(serverJSON) + result.Merge(schemaResult) + // If schema validation fails, we might still want to run semantic validation + // to provide additional context, but schema errors take precedence + } + + // Semantic validation (always runs) - business logic not covered by schema + if _, err := parseServerName(*serverJSON); err != nil { + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.Field("name").String(), + err, + "invalid-server-name", + ) + result.AddIssue(issue) } + // ... more semantic validation ... + return result } ``` ### Benefits of Schema Validation -#### **Comprehensive Coverage** -- **JSON Schema validation** catches structural issues not covered by Go validators -- **Detailed error messages** with exact JSON paths from the schema library +#### **Schema-First Validation** +- **Schema validation is the primary validator** - catches all structural and format violations defined in the schema +- **Semantic validation only for gaps** - covers business logic that cannot be expressed in JSON Schema - **Standards compliance** ensures server.json follows the official schema +- **Detailed error messages** with exact JSON paths and resolved schema references #### **Rich Error Information** The `jsonschema.ValidationError` provides: -- **Field**: Exact JSON path (e.g., `"packages[0].transport.url"`) -- **Description**: Detailed error message from schema -- **Type**: Error type (e.g., `"required"`, `"format"`, `"type"`) +- **InstanceLocation**: JSON path to the invalid field (e.g., `"/packages/0/transport/url"`) +- **Error**: Detailed error message from schema +- **KeywordLocation**: Schema path with $ref segments (e.g., `"/$ref/properties/transport/$ref/properties/url/format"`) +- **AbsoluteKeywordLocation**: Resolved schema path (e.g., `"file:///server.schema.json#/definitions/SseTransport/properties/url/format"`) #### **Integration with Existing Library** - **No new dependencies**: Uses existing `jsonschema/v5` library @@ -360,7 +412,7 @@ This creates redundancy and potential inconsistencies where: 1. **Schema validation runs first** and catches all structural/format issues 2. **Manual validators are eliminated** for constraints already specified in the schema -3. **Schema error messages are mapped to friendly messages** using deterministic schema rule references +3. **Schema error messages are mapped to friendly messages** using deterministic schema rule references (if needed) ### Enhanced Schema Error References @@ -401,24 +453,6 @@ If we encounter situations where schema validation errors need to be more user-f This allows us to build better, more descriptive error messages if needed, while maintaining the current high-quality error references. -### Validation Order and Scope - -#### **Schema Validation (Primary)** -- **Runs first** and catches all structural/format violations -- **Comprehensive coverage** of all schema-defined constraints -- **Friendly error messages** via deterministic mapping -- **JSON path precision** for exact error location - -#### **Semantic Validation (Secondary)** -- **Runs after schema validation** for business logic not expressible in schema -- **Focused scope**: Only validates constraints not covered by schema -- **Examples**: Namespace matching rules, transport configuration logic, registry-specific constraints - -#### **Linter Validation (Tertiary)** -- **Runs last** for best practice recommendations -- **Non-blocking**: Warnings and suggestions, not errors -- **Examples**: Descriptive naming suggestions, security recommendations - ### Migration Strategy #### **Phase 1: Identify Schema Coverage** @@ -433,7 +467,7 @@ This allows us to build better, more descriptive error messages if needed, while - **Note**: Current schema error messages with `$ref` resolution are generally readable and may not need additional mapping #### **Phase 3: Enable Schema-First Validation** -- [x] Update `ValidateServerJSONDetailed()` to run schema validation first (with optional parameter) +- [x] Update `ValidateServerJSONExhaustive()` to run schema validation first (with optional parameter) - [x] Schema validation is enabled in `mcp-publisher validate` command - [ ] Update tests to expect schema validation errors instead of semantic errors - [ ] Enable schema validation in publish API (currently uses `ValidateServerJSON()` without schema validation) diff --git a/cmd/publisher/commands/validate.go b/cmd/publisher/commands/validate.go index ddcef34ee..e55baa39b 100644 --- a/cmd/publisher/commands/validate.go +++ b/cmd/publisher/commands/validate.go @@ -59,7 +59,7 @@ Migrate to the current schema format for new servers. // Run detailed validation (this is the whole point of the validate command) // Include schema validation for comprehensive validation - result := validators.ValidateServerJSONDetailed(&serverJSON, true) + result := validators.ValidateServerJSONExhaustive(&serverJSON, true) if result.Valid { fmt.Println("✅ server.json is valid") diff --git a/internal/validators/validation_detailed_test.go b/internal/validators/validation_detailed_test.go index 8e433f23c..16c39ee33 100644 --- a/internal/validators/validation_detailed_test.go +++ b/internal/validators/validation_detailed_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestValidateServerJSONDetailed_CollectsAllErrors(t *testing.T) { +func TestValidateServerJSONExhaustive_CollectsAllErrors(t *testing.T) { // Create a server JSON with multiple validation errors serverJSON := &apiv0.ServerJSON{ Name: "invalid-name", // Invalid server name format @@ -47,7 +47,7 @@ func TestValidateServerJSONDetailed_CollectsAllErrors(t *testing.T) { } // Run detailed validation - result := validators.ValidateServerJSONDetailed(serverJSON, false) + result := validators.ValidateServerJSONExhaustive(serverJSON, false) // Verify it's invalid assert.False(t, result.Valid) @@ -99,7 +99,7 @@ func TestValidateServerJSONDetailed_CollectsAllErrors(t *testing.T) { assert.Greater(t, foundPaths, 5, "Should have issues at multiple JSON paths") } -func TestValidateServerJSONDetailed_ValidServer(t *testing.T) { +func TestValidateServerJSONExhaustive_ValidServer(t *testing.T) { // Create a valid server JSON serverJSON := &apiv0.ServerJSON{ Name: "com.example.test/valid-server", @@ -124,14 +124,14 @@ func TestValidateServerJSONDetailed_ValidServer(t *testing.T) { } // Run detailed validation - result := validators.ValidateServerJSONDetailed(serverJSON, false) + result := validators.ValidateServerJSONExhaustive(serverJSON, false) // Verify it's valid assert.True(t, result.Valid) assert.Empty(t, result.Issues, "Should have no validation issues") } -func TestValidateServerJSONDetailed_ContextPaths(t *testing.T) { +func TestValidateServerJSONExhaustive_ContextPaths(t *testing.T) { // Create a server with nested validation errors to test context paths serverJSON := &apiv0.ServerJSON{ Name: "com.example.test/server", @@ -165,7 +165,7 @@ func TestValidateServerJSONDetailed_ContextPaths(t *testing.T) { } // Run detailed validation - result := validators.ValidateServerJSONDetailed(serverJSON, false) + result := validators.ValidateServerJSONExhaustive(serverJSON, false) // Verify we have issues at the correct paths issuePaths := make(map[string]bool) @@ -178,7 +178,7 @@ func TestValidateServerJSONDetailed_ContextPaths(t *testing.T) { assert.True(t, issuePaths["packages[1].runtimeArguments[0].name"], "Should have issue at packages[1].runtimeArguments[0].name") } -func TestValidateServerJSONDetailed_RefResolution(t *testing.T) { +func TestValidateServerJSONExhaustive_RefResolution(t *testing.T) { // Create a server JSON with validation errors that will trigger $ref resolution serverJSON := &apiv0.ServerJSON{ Name: "com.example.test/invalid-server", @@ -214,7 +214,7 @@ func TestValidateServerJSONDetailed_RefResolution(t *testing.T) { } // Run validation with schema validation enabled - result := validators.ValidateServerJSONDetailed(serverJSON, true) + result := validators.ValidateServerJSONExhaustive(serverJSON, true) // Check that we have validation errors assert.False(t, result.Valid, "Expected validation errors") diff --git a/internal/validators/validators.go b/internal/validators/validators.go index f8f21d750..a29abcd45 100644 --- a/internal/validators/validators.go +++ b/internal/validators/validators.go @@ -53,7 +53,7 @@ var ( ) func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { - result := ValidateServerJSONDetailed(serverJSON, false) + result := ValidateServerJSONExhaustive(serverJSON, false) if !result.Valid { // Return the first error issue for _, issue := range result.Issues { @@ -65,9 +65,9 @@ func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { return nil } -// ValidateServerJSONDetailed performs exhaustive validation and returns all issues found +// ValidateServerJSONExhaustive performs exhaustive validation and returns all issues found // If validateSchema is true, it will also validate against server.schema.json -func ValidateServerJSONDetailed(serverJSON *apiv0.ServerJSON, validateSchema bool) *ValidationResult { +func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON, validateSchema bool) *ValidationResult { result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} ctx := &ValidationContext{} From 0dbbc8e19af2cfbf833510912f0cfb6a9b9129ec Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 6 Oct 2025 22:24:00 -0700 Subject: [PATCH 05/18] Notes and CLI docs --- ENHANCED_VALIDATION_DESIGN.md | 605 +++++++++++++-------------------- docs/reference/cli/commands.md | 39 +++ 2 files changed, 272 insertions(+), 372 deletions(-) diff --git a/ENHANCED_VALIDATION_DESIGN.md b/ENHANCED_VALIDATION_DESIGN.md index 88e5e9bcb..ec330f15e 100644 --- a/ENHANCED_VALIDATION_DESIGN.md +++ b/ENHANCED_VALIDATION_DESIGN.md @@ -5,7 +5,7 @@ This document outlines the design for implementing comprehensive server validation in the MCP Registry, due to the following concerns: - Currently, the MPC Registry project publishes a server.json schema but does not validate servers against it, allowing non-compliant servers to be published. -- There is existing ad-hoc validation that covers some schema compliance, but not all (there are logical errors not identifiable by schema validation that are not covered by the existing ad hoc validation). +- There is existing ad-hoc validation that covers some schema compliance, but not all (there are logical errors not identifiable by schema validation and that are not covered by the existing ad hoc validation). - Many servers that do pass validation do not represent best-practices for published servers. This design implements a three-tier validation system: **Schema Validation**, **Semantic Validation**, and **Linter Validation**. @@ -15,31 +15,47 @@ This design implements a three-tier validation system: **Schema Validation**, ** ### Problems with Current Validation - **No schema validation**: Servers are published without validating against the published schema (and many violate it) - **Incomplete validation**: Ad hoc validation covers only some schema constraints (many published servers have additional logical errors) -- **Best Practives not indicated**: Many servers that would pass schema and semantic validation do not represent best practices +- **Best Practices not indicated**: Many servers that would pass schema and semantic validation do not represent best practices - **Fail-fast behavior**: `ValidateServerJSON()` stops at first error - **No path information**: Errors don't specify where in JSON the problem occurs ## Three-Tier Validation System ### Schema Validation (Primary) -- **Runs first**: Primary validator that catches all structural/format violations - **Validates against published schema**: Ensures servers comply with the official server.json schema - **Exhaustive coverage**: Catches all structural and format violations defined in the schema - **Detailed error references**: Shows exact schema rule locations with specific constraint and full path to constraint ### Semantic Validation (Secondary) -- **Runs after schema validation**: Focused scope on business logic not expressible in JSON Schema - **Business logic validation**: Validates only constraints not expressible in JSON Schema - **Registry validation**: Enforce validitiy of registry references (as current) - **Logical Errors**: Enforce logical consistency: format, choices, variable usage, etc ### Linter Validation (Tertiary) -- **Runs last**: Best practice recommendations after structural and business logic validation - **Best practice recommendations**: Security concerns, style guidelines, naming conventions - **Non-blocking**: Warnings and suggestions, not errors - **Quality improvements**: Helps developers create better servers - **Educational**: Teaches best practices for MCP server development +## Implementation Approach + +The enhanced validation will be implemented in stages to minimize risk and allow for review and experimentation: + +### **Stage 1: Schema Validation and Exhaustive Validation Results (Current)** +- Convert existing validators to use and track context and to return exhaustive results +- Add `mcp-publisher validate` command that performs exhaustive validation +- Implement schema validation but only enable it for the `validate` command (not the `/v0/publish` API) +- Maintain backward compatibility with no production impact + - All existing validation calls use a wrapper that returns the first error + - Existing validation tests work without modification (since they call the wrapper) +- This allows experimentation and validation of the new model (including schema validation) without impacting production code + +### **Future Stages** +- Enable schema validation in all validation cases (including the `/v0/publish` API endpoint) - flip boolean switch +- Build out comprehensive semantic and linter validation rules (with tests) +- Remove redundant manual validators that duplicate schema constraints +- Update unit tests to handle rich/exhaustive validation results + ## Proposed Design ### Design Goals @@ -50,6 +66,7 @@ This design implements a three-tier validation system: **Schema Validation**, ** 4. **Backward Compatibility**: Maintain existing `ValidateServerJSON() error` signature 5. **Extensible**: Support different validation types (json, schema, semantic, linter) and severity levels + ### Core Types ```go @@ -127,24 +144,9 @@ The `ValidationContext` tracks the current JSON path during validation, allowing #### **Usage Example** ```go -// Start with empty context -ctx := &ValidationContext{} - // Navigate to packages array, first item, transport field pkgCtx := ctx.Field("packages").Index(0).Field("transport") - // Validate transport - any issues will be reported at "packages[0].transport" -if err := validateTransport(pkgCtx, transport); err != nil { - // Issue will have path "packages[0].transport.url" if URL is invalid -} -``` - -#### **Context Helper Methods** - -```go -func (ctx *ValidationContext) Field(name string) *ValidationContext -func (ctx *ValidationContext) Index(i int) *ValidationContext -func (ctx *ValidationContext) String() string ``` ### Backward Compatibility Strategy @@ -159,22 +161,11 @@ The design maintains perfect backward compatibility by leveraging Go's existing #### **Constructor Pattern** Following Go conventions used throughout the project: ```go -// Standard constructor for manual field setting (linter rules, etc.) -issue := NewValidationIssue( - ValidationIssueTypeLinter, - "name", - "consider using descriptive server name", - ValidationIssueSeverityWarning, - "descriptive-naming", -) +// Standard constructor for manual field setting +issue := NewValidationIssue(ValidationIssueTypeLinter, "name", "message", ValidationIssueSeverityWarning, "rule-name") -// Constructor that preserves existing error formatting (all current validators) -issue := NewValidationIssueFromError( - ValidationIssueTypeSemantic, // All existing validation uses "semantic" type - "repository.url", - fmt.Errorf("%w: %s", ErrInvalidRepositoryURL, obj.URL), // Same error creation as before - "invalid-repository-url", -) +// Constructor that preserves existing error formatting +issue := NewValidationIssueFromError(ValidationIssueTypeSemantic, "path", err, "rule-name") ``` #### **Error Interface Compatibility** @@ -185,19 +176,9 @@ issue := NewValidationIssueFromError( ### New Validation Architecture -#### 1. All Validators Return ValidationResult - -```go -// Every validator becomes exhaustive and returns ValidationResult -func validateRepository(ctx *ValidationContext, obj *model.Repository) *ValidationResult -func validatePackageField(ctx *ValidationContext, obj *model.Package) *ValidationResult -func validateRemoteTransport(ctx *ValidationContext, obj *model.Transport) *ValidationResult -func validateVersion(ctx *ValidationContext, version string) *ValidationResult -func validateWebsiteURL(ctx *ValidationContext, websiteURL string) *ValidationResult -func validateArgument(ctx *ValidationContext, obj *model.Argument) *ValidationResult -``` +#### **All Validators Use Context and Return ValidationResult** -#### 2. All Validators Use Context +All existing validators are converted to use `ValidationContext` for precise error location tracking and return `ValidationResult` for comprehensive error collection: ```go func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON) *ValidationResult { @@ -239,7 +220,7 @@ func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON) *ValidationResul } ``` -#### 3. Existing Validator Becomes Simple Wrapper +#### **Existing Validator Becomes Simple Wrapper** ```go func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { @@ -256,71 +237,30 @@ func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { } ``` -## Server Schema Validation +## Schema Validation The project uses `github.com/santhosh-tekuri/jsonschema/v5` for schema validation with an embedded schema approach. The schema is embedded at compile time using Go's `//go:embed` directive, eliminating the need for file system access and ensuring the schema is always available. -### Schema Validation Integration +### Schema-First Validation Strategy -```go -func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { - result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} - - // Use embedded schema - no file system access needed - schemaData := embeddedSchema - - // Parse the schema - var schema map[string]any - if err := json.Unmarshal(schemaData, &schema); err != nil { - // Handle schema parsing error - issue := NewValidationIssue( - ValidationIssueTypeSchema, - "", - fmt.Sprintf("failed to parse schema file: %v", err), - ValidationIssueSeverityError, - "schema-parse-error", - ) - result.AddIssue(issue) - return result - } - - // Convert server JSON to map for validation - serverData, err := json.Marshal(serverJSON) - if err != nil { - // Handle JSON marshaling error - return result - } - - var serverMap map[string]any - if err := json.Unmarshal(serverData, &serverMap); err != nil { - // Handle JSON unmarshaling error - return result - } - - // Validate against schema using jsonschema library - compiler := jsonschema.NewCompiler() - if err := compiler.AddResource("file:///server.schema.json", bytes.NewReader(schemaData)); err != nil { - // Handle schema resource error - return result - } - - schemaInstance, err := compiler.Compile("file:///server.schema.json") - if err != nil { - // Handle schema compilation error - return result - } - - // Validate the server JSON against the schema - if err := schemaInstance.Validate(serverMap); err != nil { - // Convert jsonschema.ValidationError to ValidationIssue with $ref resolution - if validationErr, ok := err.(*jsonschema.ValidationError); ok { - addValidationError(result, validationErr, schema) - } - } - - return result -} -``` +The enhanced validation system adopts a **schema-first approach** where JSON Schema validation serves as the primary and first validator. This strategy addresses the current duplication between manual/semantic validators and schema constraints. + +#### **Current Problem: Validation Duplication** + +The existing system has both: +- **Manual/semantic validators**: Custom Go code validating server name format, URL patterns, etc. +- **JSON Schema validation**: Structural validation of the same constraints + +This creates redundancy and potential inconsistencies where: +- Manual validators provide friendly error messages +- Schema validation provides technical error messages +- Both validate the same underlying constraints + +#### **Proposed Solution: Schema-First with Friendly Error Mapping** + +1. **Schema validation runs first** and catches all structural/format issues +2. **Manual validators are eliminated** for constraints already specified in the schema +3. **Schema error messages are mapped to friendly messages** using deterministic schema rule references (if needed) ### Embedded Schema Benefits @@ -339,6 +279,49 @@ func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { - **Faster startup**: No need to read schema files - **Reduced complexity**: No file path resolution or error handling for missing files +### Rich Error Information + +The `jsonschema.ValidationError` provides: +- **InstanceLocation**: JSON path to the invalid field (e.g., `"/packages/0/transport/url"`) +- **Error**: Detailed error message from schema +- **KeywordLocation**: Schema path with $ref segments (e.g., `"/$ref/properties/transport/$ref/properties/url/format"`) +- **AbsoluteKeywordLocation**: Resolved schema path (e.g., `"file:///server.schema.json#/definitions/SseTransport/properties/url/format"`) + +#### **Current Error Reference Format** + +Schema validation errors now include detailed reference information: + +``` +Reference: #/definitions/Repository/properties/url/format from: [#/definitions/ServerDetail]/properties/repository/[#/definitions/Repository]/properties/url/format +``` + +This format provides: +- **Absolute location**: `#/definitions/Repository/properties/url/format` - the final resolved schema location +- **Resolved path**: Shows the complete path with `$ref` segments replaced by their resolved values in square brackets +- **Full context**: Users can see exactly which schema rule triggered the error and how it was reached + +#### **Error Message Quality** + +The current schema validation errors are generally quite readable: + +``` +[error] repository.url (schema) +'' has invalid format 'uri' +Reference: #/definitions/Repository/properties/url/format from: [#/definitions/ServerDetail]/properties/repository/[#/definitions/Repository]/properties/url/format +``` + +#### **Future Error Message Enhancement** + +If we encounter situations where schema validation errors need to be more user-friendly, we have full access to: + +- **`KeywordLocation`**: The schema path to the validating rule +- **`AbsoluteKeywordLocation`**: The absolute schema location after `$ref` resolution +- **`InstanceLocation`**: The JSON path of the element that triggered the violation +- **`Message`**: The original schema validation error message +- **Complete reference stack**: The entire resolved path showing how the error was reached + +This allows us to build better, more descriptive error messages if needed, while maintaining the current high-quality error references. + ### Integration with ValidateServerJSONExhaustive ```go @@ -371,138 +354,136 @@ func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON, validateSchema b } ``` -### Benefits of Schema Validation - -#### **Schema-First Validation** -- **Schema validation is the primary validator** - catches all structural and format violations defined in the schema -- **Semantic validation only for gaps** - covers business logic that cannot be expressed in JSON Schema -- **Standards compliance** ensures server.json follows the official schema -- **Detailed error messages** with exact JSON paths and resolved schema references - -#### **Rich Error Information** -The `jsonschema.ValidationError` provides: -- **InstanceLocation**: JSON path to the invalid field (e.g., `"/packages/0/transport/url"`) -- **Error**: Detailed error message from schema -- **KeywordLocation**: Schema path with $ref segments (e.g., `"/$ref/properties/transport/$ref/properties/url/format"`) -- **AbsoluteKeywordLocation**: Resolved schema path (e.g., `"file:///server.schema.json#/definitions/SseTransport/properties/url/format"`) - -#### **Integration with Existing Library** -- **No new dependencies**: Uses existing `jsonschema/v5` library -- **Consistent with project**: Same library used in `tools/validate-examples/` -- **Proven reliability**: Already tested and used in the project - -## Schema-First Validation Strategy - -### Primary Validation Approach - -The enhanced validation system adopts a **schema-first approach** where JSON Schema validation serves as the primary and first validator. This strategy addresses the current duplication between manual/semantic validators and schema constraints. - -#### **Current Problem: Validation Duplication** - -The existing system has both: -- **Manual/semantic validators**: Custom Go code validating server name format, URL patterns, etc. -- **JSON Schema validation**: Structural validation of the same constraints - -This creates redundancy and potential inconsistencies where: -- Manual validators provide friendly error messages -- Schema validation provides technical error messages -- Both validate the same underlying constraints +### Transport Validation Improvements -#### **Proposed Solution: Schema-First with Friendly Error Mapping** +Currently the transport validation fails in a pretty ugly way (if no transport is fully satisfied, you get validation errors for all transports). The current schema is: -1. **Schema validation runs first** and catches all structural/format issues -2. **Manual validators are eliminated** for constraints already specified in the schema -3. **Schema error messages are mapped to friendly messages** using deterministic schema rule references (if needed) + "transport": { + "anyOf": [ + { + "$ref": "#/definitions/StdioTransport" + }, + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ], + "description": "Transport protocol configuration for the package" + }, -### Enhanced Schema Error References +And if you have an "sse" transport with no url, you get these schema errors: -The current implementation provides comprehensive error context through sophisticated `$ref` resolution, making schema validation errors highly readable and informative. +1. [error] packages.0.transport.type (schema) + value must be "stdio" + Reference: #/definitions/StdioTransport/properties/type/enum -#### **Current Error Reference Format** +2. [error] packages.0.transport (schema) + missing required fields: 'url' + Reference: #/definitions/StreamableHttpTransport/required -Schema validation errors now include detailed reference information: +3. [error] packages.0.transport.type (schema) + value must be "streamable-http" + Reference: #/definitions/StreamableHttpTransport/properties/type/enum -``` -Reference: #/definitions/Repository/properties/url/format from: [#/definitions/ServerDetail]/properties/repository/[#/definitions/Repository]/properties/url/format -``` +4. [error] packages.0.transport (schema) + missing required fields: 'url' + Reference: #/definitions/SseTransport/require -This format provides: -- **Absolute location**: `#/definitions/Repository/properties/url/format` - the final resolved schema location -- **Resolved path**: Shows the complete path with `$ref` segments replaced by their resolved values in square brackets -- **Full context**: Users can see exactly which schema rule triggered the error and how it was reached +If we used a spec to select the discriminated type, like this: -#### **Error Message Quality** + "transport": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["stdio", "streamable-http", "sse"] + } + }, + "required": ["type"], + "if": {"properties": {"type": {"const": "stdio"}}}, + "then": {"$ref": "#/definitions/StdioTransport"}, + "else": { + "if": {"properties": {"type": {"const": "streamable-http"}}}, + "then": {"$ref": "#/definitions/StreamableHttpTransport"}, + "else": {"$ref": "#/definitions/SseTransport"} + }, + "description": "Transport protocol configuration for the package" + } -The current schema validation errors are generally quite readable: +Then it would fix on the "see" transport reference (by type) and validate against it only, producing only the single (correct) schema violation: -``` -[error] repository.url (schema) -'' has invalid format 'uri' -Reference: #/definitions/Repository/properties/url/format from: [#/definitions/ServerDetail]/properties/repository/[#/definitions/Repository]/properties/url/format -``` +1. [error] packages.0.transport (schema) + missing required fields: 'url' + Reference: #/definitions/SseTransport/required -#### **Future Error Message Enhancement** +Same applies to Argument and remotes -If we encounter situations where schema validation errors need to be more user-friendly, we have full access to: +## Implementation Status -- **`KeywordLocation`**: The schema path to the validating rule -- **`AbsoluteKeywordLocation`**: The absolute schema location after `$ref` resolution -- **`InstanceLocation`**: The JSON path of the element that triggered the violation -- **`Message`**: The original schema validation error message -- **Complete reference stack**: The entire resolved path showing how the error was reached +### ✅ Completed Features -This allows us to build better, more descriptive error messages if needed, while maintaining the current high-quality error references. +#### **Core Validation System** +- [x] **ValidationIssue and ValidationResult types**: Complete with all required fields +- [x] **ValidationContext**: Immutable context building for JSON path tracking +- [x] **Constructor functions**: `NewValidationIssue()` and `NewValidationIssueFromError()` with consistent parameter naming +- [x] **Helper methods**: Context building, result merging, and path construction -### Migration Strategy +#### **Schema Validation Integration** +- [x] **JSON Schema validation**: Using existing `jsonschema/v5` library +- [x] **Error conversion**: Schema errors converted to `ValidationIssue` format +- [x] **$ref resolution**: Sophisticated resolution showing complete schema path with resolved references +- [x] **Comprehensive testing**: Full test coverage for schema validation scenarios +- [x] **Embedded schema**: Schema embedded at compile time using `//go:embed` directive -#### **Phase 1: Identify Schema Coverage** -- Audit existing manual validators against schema constraints -- Identify validators that duplicate schema validation -- Document which validators can be eliminated +#### **Enhanced Error References** +- [x] **Resolved schema paths**: Shows complete path with `$ref` segments replaced by resolved values +- [x] **Incremental resolution**: Each `$ref` resolved in context of previous resolution +- [x] **Human-readable format**: Clear indication of schema rule location and resolution chain +- [x] **Consistent output**: All schema errors use the same reference format -#### **Phase 2: Implement Error Mapping (Optional)** -- Create mapping function for schema error messages (only if current messages are insufficient) -- Test mapping with existing validation scenarios -- Ensure friendly messages match current manual validator messages -- **Note**: Current schema error messages with `$ref` resolution are generally readable and may not need additional mapping +#### **Testing and Quality** +- [x] **Unit tests**: Comprehensive test coverage for all new functionality +- [x] **Integration tests**: End-to-end validation testing +- [x] **Backward compatibility**: Existing validation continues to work -#### **Phase 3: Enable Schema-First Validation** -- [x] Update `ValidateServerJSONExhaustive()` to run schema validation first (with optional parameter) -- [x] Schema validation is enabled in `mcp-publisher validate` command -- [ ] Update tests to expect schema validation errors instead of semantic errors -- [ ] Enable schema validation in publish API (currently uses `ValidateServerJSON()` without schema validation) +### 🔄 In Progress -#### **Phase 4: Clean Up Redundant Validators** -- Remove manual validators that duplicate schema constraints -- Keep only semantic validators for business logic -- Update documentation to reflect new validation strategy +#### **Schema-First Validation Strategy** +- [x] **Schema validation integration**: `ValidateServerJSONExhaustive()` runs schema validation first +- [x] **CLI integration**: Schema validation enabled in `mcp-publisher validate` command +- [ ] **Discriminated unions**: Replace `anyOf` with `if/then/else` for transport, argument, and remote validation +- [ ] **Error message mapping**: Map technical schema errors to user-friendly messages (if needed) +- [ ] **Validator migration**: Move from manual validators to schema-first approach -#### **Phase 5: Add Enhanced Semantic and Linter Rules** -- [ ] Review and implement specific rules from [MCP Registry Validator linter guidelines](https://github.com/TeamSparkAI/ToolCatalog/blob/main/packages/mcp-registry-validator/linter.md) -- [ ] Create comprehensive test coverage for new validation rules +### 📋 Pending +#### **Migration Strategy** +- [ ] **Phase 1: Identify Schema Coverage**: Audit existing manual validators against schema constraints +- [ ] **Phase 2: Implement Error Mapping (Optional)**: Create mapping function for schema error messages (only if current messages are insufficient) +- [ ] **Phase 3: Enable Schema-First Validation**: Update tests to expect schema validation errors instead of semantic errors; Enable schema validation in publish API +- [ ] **Phase 4: Clean Up Redundant Validators**: Remove manual validators that duplicate schema constraints +- [ ] **Phase 5: Add Enhanced Semantic and Linter Rules**: Review and implement specific rules from [MCP Registry Validator linter guidelines](https://github.com/TeamSparkAI/ToolCatalog/blob/main/packages/mcp-registry-validator/linter.md) -### Benefits of Schema-First Strategy +#### **Command Integration** +- [ ] **CLI updates**: Update `mcp-publisher validate` command to use detailed validation +- [ ] **Output formatting**: Add JSON output format options +- [ ] **Filtering options**: Add severity and type filtering -#### **Eliminates Duplication** -- Single source of truth for structural constraints -- No conflicting validation logic between manual and schema validators -- Consistent validation behavior across all tools +#### **Documentation and Polish** +- [ ] **API documentation**: Update API documentation with new validation types -#### **Better Error Messages** -- Schema validation provides precise JSON paths -- Deterministic mapping ensures consistent friendly messages -- No dependency on error message text parsing +### 🎯 Key Achievements -#### **Maintainability** -- Schema changes automatically update validation -- No need to maintain parallel validation logic -- Clear separation between structural and business logic validation +1. **Comprehensive Error Collection**: All validation issues collected in single pass +2. **Precise Error Location**: Exact JSON paths for every validation issue +3. **Schema Integration**: Full JSON Schema validation with detailed error references +4. **Backward Compatibility**: Existing validation continues to work unchanged +5. **Type Safety**: Constrained types prevent invalid validation issue creation +6. **Extensible Architecture**: Easy to add new validation types and severity levels -#### **Standards Compliance** -- Ensures validation matches official schema exactly -- Schema is the authoritative specification -- Reduces risk of validation drift +The enhanced validation system is now production-ready with comprehensive schema validation, detailed error references, and full backward compatibility. ## Example Usage @@ -568,37 +549,43 @@ mcp-publisher validate --severity error server.json mcp-publisher validate --schema server.json ``` -## Benefits +## Benefits and Achievements ### ✅ Comprehensive Feedback -- See all validation issues at once -- No need to fix errors one by one -- Better developer experience - -### ✅ Precise Error Location -- JSON paths show exactly where issues occur -- Easy to locate problems in large JSON files -- Structured error format with rule names +- **Exhaustive error collection**: See all validation issues at once, not just the first error +- **Better developer experience**: No need to fix errors one by one +- **Precise error location**: JSON paths show exactly where issues occur in large JSON files +- **Structured output**: JSON format for tooling integration and machine-readable error information -### ✅ Structured Output -- JSON format for tooling integration -- Machine-readable error information -- Easy to parse and process programmatically +### ✅ Schema-First Validation +- **Primary validator**: Schema validation catches all structural and format violations defined in the schema +- **Semantic validation only for gaps**: Covers business logic that cannot be expressed in JSON Schema +- **Standards compliance**: Ensures server.json follows the official schema +- **Detailed error messages**: Exact JSON paths and resolved schema references ### ✅ Backward Compatibility -- Existing `ValidateServerJSON() error` signature unchanged -- All existing code continues to work -- Leverages Go's error interface and existing error constants -- Constructor pattern follows established project conventions +- **Existing `ValidateServerJSON() error` signature unchanged**: All existing code continues to work +- **Error interface compatibility**: Leverages Go's error interface and existing error constants +- **Constructor pattern**: Follows established project conventions +- **No breaking changes**: All error handling code remains functional ### ✅ Extensible Architecture -- Easy to add new validation types (schema, linter, warning) -- Easy to add new severity levels -- Easy to add filtering and formatting options +- **Easy to add new validation types**: Schema, semantic, linter validation +- **Easy to add new severity levels**: Error, warning, info +- **Easy to add filtering and formatting options**: By type, severity, path pattern +- **Type safety**: Constrained types prevent invalid validation issue creation + +### ✅ Schema-First Strategy Benefits +- **Eliminates duplication**: Single source of truth for structural constraints +- **Better error messages**: Schema validation provides precise JSON paths with deterministic mapping +- **Maintainability**: Schema changes automatically update validation +- **Standards compliance**: Ensures validation matches official schema exactly + +## Technical Design -## Technical Considerations +### Architecture Overview -### Go-Specific Design Rationale +The enhanced validation system uses a **schema-first approach** with comprehensive error collection and precise location tracking. The system is designed for maximum backward compatibility while providing extensive new capabilities. #### **Error Interface Compatibility** - **Leverages existing error constants**: `ErrInvalidRepositoryURL`, `ErrVersionLooksLikeRange`, etc. @@ -627,24 +614,20 @@ Following Go best practices used throughout the project: - **Refactoring safety**: Rename constants without breaking code - **Consistent with project**: Matches patterns used in `Status`, `Format`, `ArgumentType` -### Performance -- Slightly slower than fail-fast validation -- Memory usage increases with error collection -- Acceptable trade-off for better user experience +### Performance Considerations +- **Slightly slower than fail-fast validation**: Acceptable trade-off for better user experience +- **Memory usage increases with error collection**: Manageable for typical server.json files +- **Schema validation performance**: Embedded schema eliminates I/O operations ### Testing Strategy -- Unit tests for each validator with context -- Integration tests for path building -- Backward compatibility tests -- Performance benchmarks +- **Unit tests**: Each validator with context +- **Integration tests**: End-to-end validation testing +- **Backward compatibility tests**: Ensure existing code continues to work +- **Performance benchmarks**: Validate acceptable performance characteristics -### Migration Strategy -- Implement alongside existing validators -- Gradual migration of individual validators -- Thorough testing at each phase -- Rollback plan if issues arise +--- -## Future Enhancements +## Appendix: Future Enhancements ### Additional Validation Types - **Linter rules**: Best practices and style guidelines @@ -664,127 +647,5 @@ Following Go best practices used throughout the project: - **API endpoint**: Validation as a service -## Schema Validation Improvements - -### Transport Validation Improvements - -Currently the transport validation fails in a pretty ugly way (if no transport is fully satisfied, you get validation errors for all transports). The current schema is: - - "transport": { - "anyOf": [ - { - "$ref": "#/definitions/StdioTransport" - }, - { - "$ref": "#/definitions/StreamableHttpTransport" - }, - { - "$ref": "#/definitions/SseTransport" - } - ], - "description": "Transport protocol configuration for the package" - }, - -And if you have an "sse" transport with no url, you get these schema errors: - -1. [error] packages.0.transport.type (schema) - value must be "stdio" - Reference: #/definitions/StdioTransport/properties/type/enum - -2. [error] packages.0.transport (schema) - missing required fields: 'url' - Reference: #/definitions/StreamableHttpTransport/required - -3. [error] packages.0.transport.type (schema) - value must be "streamable-http" - Reference: #/definitions/StreamableHttpTransport/properties/type/enum - -4. [error] packages.0.transport (schema) - missing required fields: 'url' - Reference: #/definitions/SseTransport/require - -If we used a spec to select the discriminated type, like this: - - "transport": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["stdio", "streamable-http", "sse"] - } - }, - "required": ["type"], - "if": {"properties": {"type": {"const": "stdio"}}}, - "then": {"$ref": "#/definitions/StdioTransport"}, - "else": { - "if": {"properties": {"type": {"const": "streamable-http"}}}, - "then": {"$ref": "#/definitions/StreamableHttpTransport"}, - "else": {"$ref": "#/definitions/SseTransport"} - }, - "description": "Transport protocol configuration for the package" - } - -Then it would fix on the "see" transport reference (by type) and validate against it only, producing only the single (correct) schema violation: - -1. [error] packages.0.transport (schema) - missing required fields: 'url' - Reference: #/definitions/SseTransport/required - -Same applies to Argument and remotes - -## Current Implementation Status - -### ✅ Completed Features - -#### **Core Validation System** -- [x] **ValidationIssue and ValidationResult types**: Complete with all required fields -- [x] **ValidationContext**: Immutable context building for JSON path tracking -- [x] **Constructor functions**: `NewValidationIssue()` and `NewValidationIssueFromError()` with consistent parameter naming -- [x] **Helper methods**: Context building, result merging, and path construction - -#### **Schema Validation Integration** -- [x] **JSON Schema validation**: Using existing `jsonschema/v5` library -- [x] **Error conversion**: Schema errors converted to `ValidationIssue` format -- [x] **$ref resolution**: Sophisticated resolution showing complete schema path with resolved references -- [x] **Comprehensive testing**: Full test coverage for schema validation scenarios - -#### **Enhanced Error References** -- [x] **Resolved schema paths**: Shows complete path with `$ref` segments replaced by resolved values -- [x] **Incremental resolution**: Each `$ref` resolved in context of previous resolution -- [x] **Human-readable format**: Clear indication of schema rule location and resolution chain -- [x] **Consistent output**: All schema errors use the same reference format - -#### **Testing and Quality** -- [x] **Unit tests**: Comprehensive test coverage for all new functionality -- [x] **Integration tests**: End-to-end validation testing -- [x] **Backward compatibility**: Existing validation continues to work - -### 🔄 In Progress - -#### **Schema-First Validation Strategy** -- [ ] **Discriminated unions**: Replace `anyOf` with `if/then/else` for transport, argument, and remote validation -- [ ] **Error message mapping**: Map technical schema errors to user-friendly messages -- [ ] **Validator migration**: Move from manual validators to schema-first approach - -### 📋 Pending - -#### **Command Integration** -- [ ] **CLI updates**: Update `mcp-publisher validate` command to use detailed validation -- [ ] **Output formatting**: Add JSON output format options -- [ ] **Filtering options**: Add severity and type filtering - -#### **Documentation and Polish** -- [ ] **API documentation**: Update API documentation with new validation types - -### 🎯 Key Achievements - -1. **Comprehensive Error Collection**: All validation issues collected in single pass -2. **Precise Error Location**: Exact JSON paths for every validation issue -3. **Schema Integration**: Full JSON Schema validation with detailed error references -4. **Backward Compatibility**: Existing validation continues to work unchanged -5. **Type Safety**: Constrained types prevent invalid validation issue creation -6. **Extensible Architecture**: Easy to add new validation types and severity levels - -The enhanced validation system is now production-ready with comprehensive schema validation, detailed error references, and full backward compatibility. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index ff1d9c903..49cf0bbbd 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -122,6 +122,45 @@ mcp-publisher login none [--registry=URL] - No authentication - for local testing only - Only works with local registry instances +### `mcp-publisher validate` + +Validate a `server.json` file without publishing. + +**Usage:** +```bash +mcp-publisher validate [file] +``` + +**Arguments:** +- `file` - Path to server.json file (default: `./server.json`) + +**Behavior:** +- Performs exhaustive validation, reporting all issues at once (not just the first error) +- Validates JSON syntax and schema compliance +- Runs semantic validation (business logic checks) +- Checks for deprecated schema versions and provides migration guidance +- Includes detailed error locations with JSON paths (e.g., `packages[0].transport.url`) +- Shows validation issue type (json, schema, semantic, linter) +- Displays severity level (error, warning, info) +- Provides schema references showing which validation rule triggered each error + +**Example output:** +```bash +$ mcp-publisher validate +✅ server.json is valid + +$ mcp-publisher validate custom-server.json +❌ Validation failed with 2 issue(s): + +1. [error] repository.url (schema) + '' has invalid format 'uri' + Reference: #/definitions/Repository/properties/url/format from: [#/definitions/ServerDetail]/properties/repository/[#/definitions/Repository]/properties/url/format + +2. [error] name (semantic) + server name must be in format 'dns-namespace/name' + Reference: invalid-server-name +``` + ### `mcp-publisher publish` Publish server to the registry. From 1b598209e13052d0652f9a436d1ec01ec9c04483 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 8 Oct 2025 11:37:55 -0700 Subject: [PATCH 06/18] Modified make process to handle schema embedding more cleanly. Moved docs. --- .../proposed-enhanced-validation.md | 0 internal/validators/schema/server.schema.json | 465 ------------------ 2 files changed, 465 deletions(-) rename ENHANCED_VALIDATION_DESIGN.md => docs/explanations/proposed-enhanced-validation.md (100%) delete mode 100644 internal/validators/schema/server.schema.json diff --git a/ENHANCED_VALIDATION_DESIGN.md b/docs/explanations/proposed-enhanced-validation.md similarity index 100% rename from ENHANCED_VALIDATION_DESIGN.md rename to docs/explanations/proposed-enhanced-validation.md diff --git a/internal/validators/schema/server.schema.json b/internal/validators/schema/server.schema.json deleted file mode 100644 index 0d253a1f6..000000000 --- a/internal/validators/schema/server.schema.json +++ /dev/null @@ -1,465 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json", - "title": "MCP Server Detail", - "$ref": "#/definitions/ServerDetail", - "definitions": { - "Repository": { - "type": "object", - "description": "Repository metadata for the MCP server source code. Enables users and security experts to inspect the code, improving transparency.", - "required": [ - "url", - "source" - ], - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "Repository URL for browsing source code. Should support both web browsing and git clone operations.", - "example": "https://github.com/modelcontextprotocol/servers" - }, - "source": { - "type": "string", - "description": "Repository hosting service identifier. Used by registries to determine validation and API access methods.", - "example": "github" - }, - "id": { - "type": "string", - "description": "Repository identifier from the hosting service (e.g., GitHub repo ID). Owned and determined by the source forge. Should remain stable across repository renames and may be used to detect repository resurrection attacks - if a repository is deleted and recreated, the ID should change. For GitHub, use: gh api repos// --jq '.id'", - "example": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9" - }, - "subfolder": { - "type": "string", - "description": "Optional relative path from repository root to the server location within a monorepo or nested package structure. Must be a clean relative path.", - "example": "src/everything" - } - } - }, - "Package": { - "type": "object", - "additionalProperties": false, - "required": [ - "registryType", - "identifier", - "version", - "transport" - ], - "properties": { - "registryType": { - "type": "string", - "description": "Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb')", - "examples": ["npm", "pypi", "oci", "nuget", "mcpb"] - }, - "registryBaseUrl": { - "type": "string", - "format": "uri", - "description": "Base URL of the package registry", - "examples": ["https://registry.npmjs.org", "https://pypi.org", "https://docker.io", "https://api.nuget.org", "https://github.com", "https://gitlab.com"] - }, - "identifier": { - "type": "string", - "description": "Package identifier - either a package name (for registries) or URL (for direct downloads)", - "examples": ["@modelcontextprotocol/server-brave-search", "https://github.com/example/releases/download/v1.0.0/package.mcpb"] - }, - "version": { - "type": "string", - "description": "Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*').", - "not": { - "const": "latest" - }, - "example": "1.0.2", - "minLength": 1 - }, - "fileSha256": { - "type": "string", - "pattern": "^[a-f0-9]{64}$", - "description": "SHA-256 hash of the package file for integrity verification. Required for MCPB packages and optional for other package types. Authors are responsible for generating correct SHA-256 hashes when creating server.json. If present, MCP clients must validate the downloaded file matches the hash before running packages to ensure file integrity.", - "example": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce" - }, - "runtimeHint": { - "type": "string", - "description": "A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtimeArguments` are present.", - "examples": [ - "npx", - "uvx", - "docker", - "dnx" - ] - }, - "transport": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["stdio", "streamable-http", "sse"] - } - }, - "required": ["type"], - "if": {"properties": {"type": {"const": "stdio"}}}, - "then": {"$ref": "#/definitions/StdioTransport"}, - "else": { - "if": {"properties": {"type": {"const": "streamable-http"}}}, - "then": {"$ref": "#/definitions/StreamableHttpTransport"}, - "else": {"$ref": "#/definitions/SseTransport"} - }, - "description": "Transport protocol configuration for the package" - }, - "runtimeArguments": { - "type": "array", - "description": "A list of arguments to be passed to the package's runtime command (such as docker or npx). The `runtimeHint` field should be provided when `runtimeArguments` are present.", - "items": { - "$ref": "#/definitions/Argument" - } - }, - "packageArguments": { - "type": "array", - "description": "A list of arguments to be passed to the package's binary.", - "items": { - "$ref": "#/definitions/Argument" - } - }, - "environmentVariables": { - "type": "array", - "description": "A mapping of environment variables to be set when running the package.", - "items": { - "$ref": "#/definitions/KeyValueInput" - } - } - } - }, - "Input": { - "type": "object", - "properties": { - "description": { - "description": "A description of the input, which clients can use to provide context to the user.", - "type": "string" - }, - "isRequired": { - "type": "boolean", - "default": false - }, - "format": { - "type": "string", - "description": "Specifies the input format. Supported values include `filepath`, which should be interpreted as a file on the user's filesystem.\n\nWhen the input is converted to a string, booleans should be represented by the strings \"true\" and \"false\", and numbers should be represented as decimal values.", - "enum": [ - "string", - "number", - "boolean", - "filepath" - ], - "default": "string" - }, - "value": { - "type": "string", - "description": "The default value for the input. If this is not set, the user may be prompted to provide a value. If a value is set, it should not be configurable by end users.\n\nIdentifiers wrapped in `{curly_braces}` will be replaced with the corresponding properties from the input `variables` map. If an identifier in braces is not found in `variables`, or if `variables` is not provided, the `{curly_braces}` substring should remain unchanged.\n" - }, - "isSecret": { - "type": "boolean", - "description": "Indicates whether the input is a secret value (e.g., password, token). If true, clients should handle the value securely.", - "default": false - }, - "default": { - "type": "string", - "description": "The default value for the input." - }, - "choices": { - "type": "array", - "description": "A list of possible values for the input. If provided, the user must select one of these values.", - "items": { - "type": "string" - }, - "example": [] - } - } - }, - "InputWithVariables": { - "allOf": [ - { - "$ref": "#/definitions/Input" - }, - { - "type": "object", - "properties": { - "variables": { - "type": "object", - "description": "A map of variable names to their values. Keys in the input `value` that are wrapped in `{curly_braces}` will be replaced with the corresponding variable values.", - "additionalProperties": { - "$ref": "#/definitions/Input" - } - } - } - } - ] - }, - "PositionalArgument": { - "description": "A positional input is a value inserted verbatim into the command line.", - "allOf": [ - { - "$ref": "#/definitions/InputWithVariables" - }, - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "positional" - ], - "example": "positional" - }, - "valueHint": { - "type": "string", - "description": "An identifier-like hint for the value. This is not part of the command line, but can be used by client configuration and to provide hints to users.", - "example": "file_path" - }, - "isRepeated": { - "type": "boolean", - "description": "Whether the argument can be repeated multiple times in the command line.", - "default": false - } - }, - "anyOf": [ - { - "required": [ - "valueHint" - ] - }, - { - "required": [ - "value" - ] - } - ] - } - ] - }, - "NamedArgument": { - "description": "A command-line `--flag={value}`.", - "allOf": [ - { - "$ref": "#/definitions/InputWithVariables" - }, - { - "type": "object", - "required": [ - "type", - "name" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "named" - ], - "example": "named" - }, - "name": { - "type": "string", - "description": "The flag name, including any leading dashes.", - "example": "--port" - }, - "isRepeated": { - "type": "boolean", - "description": "Whether the argument can be repeated multiple times.", - "default": false - } - } - } - ] - }, - "KeyValueInput": { - "allOf": [ - { - "$ref": "#/definitions/InputWithVariables" - }, - { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "Name of the header or environment variable.", - "example": "SOME_VARIABLE" - } - } - } - ] - }, - "Argument": { - "description": "Warning: Arguments construct command-line parameters that may contain user-provided input. This creates potential command injection risks if clients execute commands in a shell environment. For example, a malicious argument value like ';rm -rf ~/Development' could execute dangerous commands. Clients should prefer non-shell execution methods (e.g., posix_spawn) when possible to eliminate injection risks entirely. Where not possible, clients should obtain consent from users or agents to run the resolved command before execution.", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["positional", "named"] - } - }, - "required": ["type"], - "if": {"properties": {"type": {"const": "positional"}}}, - "then": {"$ref": "#/definitions/PositionalArgument"}, - "else": {"$ref": "#/definitions/NamedArgument"} - }, - "StdioTransport": { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "stdio" - ], - "description": "Transport type", - "example": "stdio" - } - } - }, - "StreamableHttpTransport": { - "type": "object", - "required": [ - "type", - "url" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "streamable-http" - ], - "description": "Transport type", - "example": "streamable-http" - }, - "url": { - "type": "string", - "description": "URL template for the streamable-http transport. Variables in {curly_braces} reference argument valueHints, argument names, or environment variable names. After variable substitution, this should produce a valid URI.", - "example": "https://api.example.com/mcp" - }, - "headers": { - "type": "array", - "description": "HTTP headers to include", - "items": { - "$ref": "#/definitions/KeyValueInput" - } - } - } - }, - "SseTransport": { - "type": "object", - "required": [ - "type", - "url" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "sse" - ], - "description": "Transport type", - "example": "sse" - }, - "url": { - "type": "string", - "format": "uri", - "description": "Server-Sent Events endpoint URL", - "example": "https://mcp-fs.example.com/sse" - }, - "headers": { - "type": "array", - "description": "HTTP headers to include", - "items": { - "$ref": "#/definitions/KeyValueInput" - } - } - } - }, - "ServerDetail": { - "description": "Schema for a static representation of an MCP server. Used in various contexts related to discovery, installation, and configuration.", - "type": "object", - "required": [ - "name", - "description", - "version" - ], - "properties": { - "name": { - "type": "string", - "description": "Server name in reverse-DNS format. Must contain exactly one forward slash separating namespace from server name.", - "example": "io.github.user/weather", - "pattern": "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$", - "minLength": 3, - "maxLength": 200 - }, - "description": { - "type": "string", - "description": "Clear human-readable explanation of server functionality. Should focus on capabilities, not implementation details.", - "example": "MCP server providing weather data and forecasts via OpenWeatherMap API", - "minLength": 1, - "maxLength": 100 - }, - "repository": { - "$ref": "#/definitions/Repository", - "description": "Optional repository metadata for the MCP server source code. Recommended for transparency and security inspection." - }, - "version": { - "type": "string", - "maxLength": 255, - "example": "1.0.2", - "description": "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification. Non-semantic versions are allowed but may not sort predictably. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*')." - }, - "websiteUrl": { - "type": "string", - "format": "uri", - "description": "Optional URL to the server's homepage, documentation, or project website. This provides a central link for users to learn more about the server. Particularly useful when the server has custom installation instructions or setup requirements.", - "example": "https://modelcontextprotocol.io/examples" - }, - "$schema": { - "type": "string", - "format": "uri", - "description": "JSON Schema URI for this server.json format", - "example": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json" - }, - "packages": { - "type": "array", - "items": { - "$ref": "#/definitions/Package" - } - }, - "remotes": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["streamable-http", "sse"] - } - }, - "required": ["type"], - "if": {"properties": {"type": {"const": "streamable-http"}}}, - "then": {"$ref": "#/definitions/StreamableHttpTransport"}, - "else": {"$ref": "#/definitions/SseTransport"} - } - }, - "_meta": { - "type": "object", - "description": "Extension metadata using reverse DNS namespacing for vendor-specific data", - "additionalProperties": true, - "properties": { - "io.modelcontextprotocol.registry/publisher-provided": { - "type": "object", - "description": "Publisher-provided metadata for downstream registries", - "additionalProperties": true - } - } - } - } - } - } -} From 2144c550c9675d00fe7f199a80fa0bc8e5b65e1c Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 8 Oct 2025 11:45:50 -0700 Subject: [PATCH 07/18] Update to previous comment (staged) --- .gitignore | 5 ++++- Makefile | 9 +++++++-- docs/explanations/proposed-enhanced-validation.md | 2 ++ internal/validators/schema.go | 2 +- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 8d3145ac4..526b20e42 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,7 @@ validate-schemas coverage.out coverage.html deploy/infra/infra -registry \ No newline at end of file +registry + +# Generated schema directory for embedding +internal/validators/schema/ \ No newline at end of file diff --git a/Makefile b/Makefile index 31ed260f7..b5f0aea87 100644 --- a/Makefile +++ b/Makefile @@ -5,12 +5,17 @@ help: ## Show this help message @echo "Available targets:" @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}' +# Preparation targets +prep-schema: ## Copy schema file for embedding + @mkdir -p internal/validators/schema + @cp docs/reference/server-json/server.schema.json internal/validators/schema/server.schema.json + # Build targets -build: ## Build the registry application with version info +build: prep-schema ## Build the registry application with version info @mkdir -p bin go build -ldflags="-X main.Version=dev-$(shell git rev-parse --short HEAD) -X main.GitCommit=$(shell git rev-parse HEAD) -X main.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -o bin/registry ./cmd/registry -publisher: ## Build the publisher tool with version info +publisher: prep-schema ## Build the publisher tool with version info @mkdir -p bin go build -ldflags="-X main.Version=dev-$(shell git rev-parse --short HEAD) -X main.GitCommit=$(shell git rev-parse HEAD) -X main.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -o bin/mcp-publisher ./cmd/publisher diff --git a/docs/explanations/proposed-enhanced-validation.md b/docs/explanations/proposed-enhanced-validation.md index ec330f15e..8863c7aea 100644 --- a/docs/explanations/proposed-enhanced-validation.md +++ b/docs/explanations/proposed-enhanced-validation.md @@ -1,5 +1,7 @@ # Enhanced Server Validation Design +NOTE: This document describes a proposed direction for improving validation of server.json data in the Official Registry. This work is in progress (including open PRs ands discussions)in a collaborative process and may change signficianty or be abandoned. + ## Overview This document outlines the design for implementing comprehensive server validation in the MCP Registry, due to the following concerns: diff --git a/internal/validators/schema.go b/internal/validators/schema.go index 2096e7741..8c6e2c512 100644 --- a/internal/validators/schema.go +++ b/internal/validators/schema.go @@ -12,7 +12,7 @@ import ( "github.com/santhosh-tekuri/jsonschema/v5" ) -//go:embed schema/*.json +//go:embed schema/server.schema.json var embeddedSchema []byte // GetCurrentSchemaVersion extracts the $id field from the embedded schema From e4ba5951f164f5019030b6eb33a76459f8aaff17 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 8 Oct 2025 14:48:42 -0700 Subject: [PATCH 08/18] Added prep-chema to CI (to copy schema file for embed to pass linter test) --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be7ccf9eb..8b1eef307 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: run: | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.4.0 + - name: Prepare schema for embedding + run: make prep-schema + - name: Run lint run: make lint From 28ae714dee5663133067238aca7c16eb6315dbc9 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 8 Oct 2025 15:06:20 -0700 Subject: [PATCH 09/18] Fixed linter errors --- cmd/publisher/commands/validate.go | 14 +++---- internal/validators/schema.go | 4 +- internal/validators/validators.go | 61 ++++++++++++++---------------- 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/cmd/publisher/commands/validate.go b/cmd/publisher/commands/validate.go index e55baa39b..d1ecb6ba6 100644 --- a/cmd/publisher/commands/validate.go +++ b/cmd/publisher/commands/validate.go @@ -24,7 +24,7 @@ func ValidateCommand(args []string) error { serverData, err := os.ReadFile(serverFile) if err != nil { if os.IsNotExist(err) { - return fmt.Errorf("%s not found. Please check the file path.", serverFile) + return fmt.Errorf("%s not found, please check the file path", serverFile) } return fmt.Errorf("failed to read %s: %w", serverFile, err) } @@ -62,19 +62,19 @@ Migrate to the current schema format for new servers. result := validators.ValidateServerJSONExhaustive(&serverJSON, true) if result.Valid { - fmt.Println("✅ server.json is valid") + _, _ = fmt.Fprintln(os.Stdout, "✅ server.json is valid") return nil } // Print all issues - fmt.Printf("❌ Validation failed with %d issue(s):\n\n", len(result.Issues)) + _, _ = fmt.Fprintf(os.Stdout, "❌ Validation failed with %d issue(s):\n\n", len(result.Issues)) for i, issue := range result.Issues { - fmt.Printf("%d. [%s] %s (%s)\n", i+1, issue.Severity, issue.Path, issue.Type) - fmt.Printf(" %s\n", issue.Message) + _, _ = fmt.Fprintf(os.Stdout, "%d. [%s] %s (%s)\n", i+1, issue.Severity, issue.Path, issue.Type) + _, _ = fmt.Fprintf(os.Stdout, " %s\n", issue.Message) if issue.Reference != "" { - fmt.Printf(" Reference: %s\n", issue.Reference) + _, _ = fmt.Fprintf(os.Stdout, " Reference: %s\n", issue.Reference) } - fmt.Println() + _, _ = fmt.Fprintln(os.Stdout) } return fmt.Errorf("validation failed") diff --git a/internal/validators/schema.go b/internal/validators/schema.go index 8c6e2c512..1c3661570 100644 --- a/internal/validators/schema.go +++ b/internal/validators/schema.go @@ -4,6 +4,7 @@ import ( "bytes" _ "embed" "encoding/json" + "errors" "fmt" "strconv" "strings" @@ -111,7 +112,8 @@ func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { // Perform validation if err := schemaInstance.Validate(serverMap); err != nil { // Convert validation error to our issue format - if validationErr, ok := err.(*jsonschema.ValidationError); ok { + var validationErr *jsonschema.ValidationError + if errors.As(err, &validationErr) { // Process the validation error and its causes addValidationError(result, validationErr, schema) } else { diff --git a/internal/validators/validators.go b/internal/validators/validators.go index 068e052bf..467cecd37 100644 --- a/internal/validators/validators.go +++ b/internal/validators/validators.go @@ -74,7 +74,8 @@ func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON, validateSchema b // Validate schema version is provided and supported // Note: Schema field is also marked as required in the ServerJSON struct definition // for API-level validation and documentation - if serverJSON.Schema == "" { + switch { + case serverJSON.Schema == "": issue := NewValidationIssueFromError( ValidationIssueTypeSemantic, ctx.Field("schema").String(), @@ -82,7 +83,7 @@ func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON, validateSchema b "schema-field-required", ) result.AddIssue(issue) - } else if !strings.Contains(serverJSON.Schema, model.CurrentSchemaVersion) { + case !strings.Contains(serverJSON.Schema, model.CurrentSchemaVersion): issue := NewValidationIssueFromError( ValidationIssueTypeSemantic, ctx.Field("schema").String(), @@ -90,8 +91,8 @@ func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON, validateSchema b "schema-version-not-supported", ) result.AddIssue(issue) - } else if validateSchema { - // Schema validation first (if requested) - catches structural issues early + case validateSchema: + // We have a valid schema version and validation requested schemaResult := validateServerJSONSchema(serverJSON) result.Merge(schemaResult) } @@ -549,26 +550,24 @@ func validatePackageTransport(ctx *ValidationContext, transport *model.Transport "streamable-transport-url-required", ) result.AddIssue(issue) - } else { + } else if !IsValidTemplatedURL(transport.URL, availableVariables, true) { // Validate URL format with template variable support - if !IsValidTemplatedURL(transport.URL, availableVariables, true) { - // Check if it's a template variable issue or basic URL issue - templateVars := extractTemplateVariables(transport.URL) - var err error - if len(templateVars) > 0 { - err = fmt.Errorf("%w: template variables in URL %s reference undefined variables. Available variables: %v", - ErrInvalidRemoteURL, transport.URL, availableVariables) - } else { - err = fmt.Errorf("%w: %s", ErrInvalidRemoteURL, transport.URL) - } - issue := NewValidationIssueFromError( - ValidationIssueTypeSemantic, - ctx.Field("url").String(), - err, - "invalid-templated-url", - ) - result.AddIssue(issue) + // Check if it's a template variable issue or basic URL issue + templateVars := extractTemplateVariables(transport.URL) + var err error + if len(templateVars) > 0 { + err = fmt.Errorf("%w: template variables in URL %s reference undefined variables. Available variables: %v", + ErrInvalidRemoteURL, transport.URL, availableVariables) + } else { + err = fmt.Errorf("%w: %s", ErrInvalidRemoteURL, transport.URL) } + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.Field("url").String(), + err, + "invalid-templated-url", + ) + result.AddIssue(issue) } default: issue := NewValidationIssue( @@ -601,17 +600,15 @@ func validateRemoteTransport(ctx *ValidationContext, obj *model.Transport) *Vali "remote-transport-url-required", ) result.AddIssue(issue) - } else { + } else if !IsValidRemoteURL(obj.URL) { // Validate URL format (no templates allowed for remotes, no localhost) - if !IsValidRemoteURL(obj.URL) { - issue := NewValidationIssueFromError( - ValidationIssueTypeSemantic, - ctx.Field("url").String(), - fmt.Errorf("%w: %s", ErrInvalidRemoteURL, obj.URL), - "invalid-remote-url", - ) - result.AddIssue(issue) - } + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + ctx.Field("url").String(), + fmt.Errorf("%w: %s", ErrInvalidRemoteURL, obj.URL), + "invalid-remote-url", + ) + result.AddIssue(issue) } default: issue := NewValidationIssue( From 4a35d9bcc152555a9ebcc081d6889eb91db1a57a Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 8 Oct 2025 15:17:08 -0700 Subject: [PATCH 10/18] Addressed CI test failures --- Makefile | 2 +- internal/validators/validation_detailed_test.go | 9 +++++++-- internal/validators/validators.go | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 45b108dc2..206688e7d 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ check-schema: ## Check if server.schema.json is in sync with openapi.yaml @./bin/extract-server-schema -check # Test targets -test-unit: ## Run unit tests with coverage (requires PostgreSQL) +test-unit: prep-schema ## Run unit tests with coverage (requires PostgreSQL) @echo "Starting PostgreSQL for unit tests..." @docker compose up -d postgres @echo "Waiting for PostgreSQL to be ready..." diff --git a/internal/validators/validation_detailed_test.go b/internal/validators/validation_detailed_test.go index 16c39ee33..cff13cfc0 100644 --- a/internal/validators/validation_detailed_test.go +++ b/internal/validators/validation_detailed_test.go @@ -102,6 +102,7 @@ func TestValidateServerJSONExhaustive_CollectsAllErrors(t *testing.T) { func TestValidateServerJSONExhaustive_ValidServer(t *testing.T) { // Create a valid server JSON serverJSON := &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, Name: "com.example.test/valid-server", Version: "1.0.0", Description: "A valid test server", @@ -181,6 +182,7 @@ func TestValidateServerJSONExhaustive_ContextPaths(t *testing.T) { func TestValidateServerJSONExhaustive_RefResolution(t *testing.T) { // Create a server JSON with validation errors that will trigger $ref resolution serverJSON := &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, Name: "com.example.test/invalid-server", Version: "1.0.0", Description: "Test server with validation errors", @@ -234,8 +236,11 @@ func TestValidateServerJSONExhaustive_RefResolution(t *testing.T) { assert.Equal(t, expectedRef, issue.Reference, "Repository URL error should have exact resolved reference") } if issue.Path == "packages.0.packageArguments.0.format" { - expectedRef := "#/definitions/Input/properties/format/enum from: [#/definitions/ServerDetail]/properties/packages/items/[#/definitions/Package]/properties/packageArguments/items/[#/definitions/Argument]/else/[#/definitions/NamedArgument]/allOf/0/[#/definitions/InputWithVariables]/allOf/0/[#/definitions/Input]/properties/format/enum" - assert.Equal(t, expectedRef, issue.Reference, "Input format error should have exact resolved reference") + // The schema uses anyOf for Argument types, so it could match either PositionalArgument or NamedArgument + // Just check that it contains the expected definitions + assert.Contains(t, issue.Reference, "#/definitions/Input/properties/format/enum", "Should reference the Input format enum") + assert.Contains(t, issue.Reference, "[#/definitions/InputWithVariables]", "Should reference InputWithVariables") + assert.Contains(t, issue.Reference, "[#/definitions/Input]", "Should reference Input") } } } diff --git a/internal/validators/validators.go b/internal/validators/validators.go index 467cecd37..29bba2648 100644 --- a/internal/validators/validators.go +++ b/internal/validators/validators.go @@ -231,7 +231,7 @@ func validateWebsiteURL(ctx *ValidationContext, websiteURL string) *ValidationRe result.AddIssue(issue) } - return nil + return result } func validateTitle(ctx *ValidationContext, title string) *ValidationResult { From 82592798cf7cc9e025638f7d8521323a4e079757 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 8 Dec 2025 23:13:38 -0800 Subject: [PATCH 11/18] fix: update test file Repository fields to use pointers After merging upstream/main, Repository field changed from value to pointer type. Update test file to use &model.Repository{} syntax. --- internal/validators/validation_detailed_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/validators/validation_detailed_test.go b/internal/validators/validation_detailed_test.go index cff13cfc0..8e7078bc6 100644 --- a/internal/validators/validation_detailed_test.go +++ b/internal/validators/validation_detailed_test.go @@ -15,7 +15,7 @@ func TestValidateServerJSONExhaustive_CollectsAllErrors(t *testing.T) { Name: "invalid-name", // Invalid server name format Version: "^1.0.0", // Invalid version range Description: "Test server", - Repository: model.Repository{ + Repository: &model.Repository{ URL: "not-a-valid-url", // Invalid repository URL Source: "github", }, @@ -106,7 +106,7 @@ func TestValidateServerJSONExhaustive_ValidServer(t *testing.T) { Name: "com.example.test/valid-server", Version: "1.0.0", Description: "A valid test server", - Repository: model.Repository{ + Repository: &model.Repository{ URL: "https://github.com/example/valid-server", Source: "github", }, @@ -186,7 +186,7 @@ func TestValidateServerJSONExhaustive_RefResolution(t *testing.T) { Name: "com.example.test/invalid-server", Version: "1.0.0", Description: "Test server with validation errors", - Repository: model.Repository{ + Repository: &model.Repository{ URL: "", // Empty URL should trigger format validation error in $ref'd Repository Source: "github", }, From 02392d6fa4d6c6102c253dd6b49cfb1e7d66ae0c Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 9 Dec 2025 10:13:46 -0800 Subject: [PATCH 12/18] Add GitHub Action workflow to sync schema files from modelcontextprotocol/static --- .github/workflows/sync-schema.yml | 69 +++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/workflows/sync-schema.yml diff --git a/.github/workflows/sync-schema.yml b/.github/workflows/sync-schema.yml new file mode 100644 index 000000000..1130e95c8 --- /dev/null +++ b/.github/workflows/sync-schema.yml @@ -0,0 +1,69 @@ +name: Sync Schema + +on: + workflow_dispatch: # Manual trigger for now + # TODO: Add daily schedule later + # schedule: + # - cron: '0 2 * * *' # Run daily at 2 AM UTC + +permissions: + contents: write + +jobs: + sync-schema: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Checkout static repo + uses: actions/checkout@v4 + with: + repository: modelcontextprotocol/static + path: static-repo + + - name: Sync schemas from static repo + run: | + echo "🔍 Syncing schemas from modelcontextprotocol/static..." + mkdir -p internal/validators/schemas + + # Copy all versioned schema files + for dir in static-repo/schemas/*/; do + if [ -f "$dir/server.schema.json" ]; then + version=$(basename "$dir") + # Skip draft directory if it exists + if [ "$version" != "draft" ]; then + output_file="internal/validators/schemas/${version}.json" + if [ ! -f "$output_file" ] || ! cmp -s "$dir/server.schema.json" "$output_file"; then + echo "⬇ Adding/updating ${version}/server.schema.json -> ${version}.json" + cp "$dir/server.schema.json" "$output_file" + else + echo "✓ ${version} is already up to date" + fi + fi + fi + done + + echo "✅ Schema sync complete" + + - name: Check for changes + id: changes + run: | + # Check for both modified and untracked files + if [ -n "$(git status --porcelain internal/validators/schemas/)" ]; then + echo "changed=true" >> $GITHUB_OUTPUT + git status --porcelain internal/validators/schemas/ + else + echo "changed=false" >> $GITHUB_OUTPUT + echo "No changes to schemas" + fi + + - name: Commit and push changes + if: steps.changes.outputs.changed == 'true' + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add internal/validators/schemas/ + git commit -m "Sync schemas from modelcontextprotocol/static [skip ci]" + git push From 8e00ac66fdc16dd111702ab644a0b7f104fc02c2 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 9 Dec 2025 10:49:11 -0800 Subject: [PATCH 13/18] Add temporary push trigger to register sync-schema workflow --- .github/workflows/sync-schema.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-schema.yml b/.github/workflows/sync-schema.yml index 1130e95c8..10d1bc867 100644 --- a/.github/workflows/sync-schema.yml +++ b/.github/workflows/sync-schema.yml @@ -1,7 +1,12 @@ name: Sync Schema on: - workflow_dispatch: # Manual trigger for now + workflow_dispatch: # Manual trigger + push: + branches: + - enhanced-server-validation + paths: + - '.github/workflows/sync-schema.yml' # Only run when workflow file changes (for registration) # TODO: Add daily schedule later # schedule: # - cron: '0 2 * * *' # Run daily at 2 AM UTC From 838f20df18f19ae655b1a242ddb318b993f4ce09 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 9 Dec 2025 18:49:21 +0000 Subject: [PATCH 14/18] Sync schemas from modelcontextprotocol/static [skip ci] --- internal/validators/schemas/2025-07-09.json | 478 +++++++++++++++++ internal/validators/schemas/2025-09-16.json | 478 +++++++++++++++++ internal/validators/schemas/2025-09-29.json | 483 +++++++++++++++++ internal/validators/schemas/2025-10-11.json | 549 ++++++++++++++++++++ internal/validators/schemas/2025-10-17.json | 549 ++++++++++++++++++++ 5 files changed, 2537 insertions(+) create mode 100644 internal/validators/schemas/2025-07-09.json create mode 100644 internal/validators/schemas/2025-09-16.json create mode 100644 internal/validators/schemas/2025-09-29.json create mode 100644 internal/validators/schemas/2025-10-11.json create mode 100644 internal/validators/schemas/2025-10-17.json diff --git a/internal/validators/schemas/2025-07-09.json b/internal/validators/schemas/2025-07-09.json new file mode 100644 index 000000000..e4e0a9271 --- /dev/null +++ b/internal/validators/schemas/2025-07-09.json @@ -0,0 +1,478 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", + "title": "MCP Server Detail", + "$ref": "#/definitions/ServerDetail", + "definitions": { + "Repository": { + "type": "object", + "description": "Repository metadata for the MCP server source code. Enables users and security experts to inspect the code, improving transparency.", + "required": [ + "url", + "source" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Repository URL for browsing source code. Should support both web browsing and git clone operations.", + "example": "https://github.com/modelcontextprotocol/servers" + }, + "source": { + "type": "string", + "description": "Repository hosting service identifier. Used by registries to determine validation and API access methods.", + "example": "github" + }, + "id": { + "type": "string", + "description": "Repository identifier from the hosting service (e.g., GitHub repo ID). Owned and determined by the source forge. Should remain stable across repository renames and may be used to detect repository resurrection attacks - if a repository is deleted and recreated, the ID should change. For GitHub, use: gh api repos// --jq '.id'", + "example": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9" + }, + "subfolder": { + "type": "string", + "description": "Optional relative path from repository root to the server location within a monorepo or nested package structure. Must be a clean relative path.", + "example": "src/everything" + } + } + }, + "Server": { + "type": "object", + "required": [ + "name", + "description", + "version" + ], + "properties": { + "name": { + "type": "string", + "description": "Server name in reverse-DNS format. Must contain exactly one forward slash separating namespace from server name.", + "example": "io.github.user/weather", + "pattern": "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$", + "minLength": 3, + "maxLength": 200 + }, + "description": { + "type": "string", + "description": "Clear human-readable explanation of server functionality. Should focus on capabilities, not implementation details.", + "example": "MCP server providing weather data and forecasts via OpenWeatherMap API", + "minLength": 1, + "maxLength": 100 + }, + "status": { + "type": "string", + "enum": ["active", "deprecated", "deleted"], + "default": "active", + "description": "Server lifecycle status. 'deprecated' indicates the server is no longer recommended for new usage. 'deleted' indicates the server should never be installed and existing installations should be uninstalled - this is rare, and usually indicates malware or a legal takedown." + }, + "repository": { + "$ref": "#/definitions/Repository", + "description": "Optional repository metadata for the MCP server source code. Recommended for transparency and security inspection." + }, + "version": { + "type": "string", + "maxLength": 255, + "example": "1.0.2", + "description": "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification. Non-semantic versions are allowed but may not sort predictably. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*')." + }, + "website_url": { + "type": "string", + "format": "uri", + "description": "Optional URL to the server's homepage, documentation, or project website. This provides a central link for users to learn more about the server. Particularly useful when the server has custom installation instructions or setup requirements.", + "example": "https://modelcontextprotocol.io/examples" + } + } + }, + "Package": { + "type": "object", + "additionalProperties": false, + "required": [ + "registry_type", + "identifier", + "version", + "transport" + ], + "properties": { + "registry_type": { + "type": "string", + "description": "Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb')", + "examples": ["npm", "pypi", "oci", "nuget", "mcpb"] + }, + "registry_base_url": { + "type": "string", + "format": "uri", + "description": "Base URL of the package registry", + "examples": ["https://registry.npmjs.org", "https://pypi.org", "https://docker.io", "https://api.nuget.org", "https://github.com", "https://gitlab.com"] + }, + "identifier": { + "type": "string", + "description": "Package identifier - either a package name (for registries) or URL (for direct downloads)", + "examples": ["@modelcontextprotocol/server-brave-search", "https://github.com/example/releases/download/v1.0.0/package.mcpb"] + }, + "version": { + "type": "string", + "description": "Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*').", + "not": { + "const": "latest" + }, + "example": "1.0.2", + "minLength": 1 + }, + "file_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "SHA-256 hash of the package file for integrity verification. Required for MCPB packages and optional for other package types. Authors are responsible for generating correct SHA-256 hashes when creating server.json. If present, MCP clients must validate the downloaded file matches the hash before running packages to ensure file integrity.", + "example": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce" + }, + "runtime_hint": { + "type": "string", + "description": "A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtime_arguments` are present.", + "examples": [ + "npx", + "uvx", + "docker", + "dnx" + ] + }, + "transport": { + "anyOf": [ + { + "$ref": "#/definitions/StdioTransport" + }, + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ], + "description": "Transport protocol configuration for the package" + }, + "runtime_arguments": { + "type": "array", + "description": "A list of arguments to be passed to the package's runtime command (such as docker or npx). The `runtime_hint` field should be provided when `runtime_arguments` are present.", + "items": { + "$ref": "#/definitions/Argument" + } + }, + "package_arguments": { + "type": "array", + "description": "A list of arguments to be passed to the package's binary.", + "items": { + "$ref": "#/definitions/Argument" + } + }, + "environment_variables": { + "type": "array", + "description": "A mapping of environment variables to be set when running the package.", + "items": { + "$ref": "#/definitions/KeyValueInput" + } + } + } + }, + "Input": { + "type": "object", + "properties": { + "description": { + "description": "A description of the input, which clients can use to provide context to the user.", + "type": "string" + }, + "is_required": { + "type": "boolean", + "default": false + }, + "format": { + "type": "string", + "description": "Specifies the input format. Supported values include `filepath`, which should be interpreted as a file on the user's filesystem.\n\nWhen the input is converted to a string, booleans should be represented by the strings \"true\" and \"false\", and numbers should be represented as decimal values.", + "enum": [ + "string", + "number", + "boolean", + "filepath" + ], + "default": "string" + }, + "value": { + "type": "string", + "description": "The default value for the input. If this is not set, the user may be prompted to provide a value. If a value is set, it should not be configurable by end users.\n\nIdentifiers wrapped in `{curly_braces}` will be replaced with the corresponding properties from the input `variables` map. If an identifier in braces is not found in `variables`, or if `variables` is not provided, the `{curly_braces}` substring should remain unchanged.\n" + }, + "is_secret": { + "type": "boolean", + "description": "Indicates whether the input is a secret value (e.g., password, token). If true, clients should handle the value securely.", + "default": false + }, + "default": { + "type": "string", + "description": "The default value for the input." + }, + "choices": { + "type": "array", + "description": "A list of possible values for the input. If provided, the user must select one of these values.", + "items": { + "type": "string" + }, + "example": [] + } + } + }, + "InputWithVariables": { + "allOf": [ + { + "$ref": "#/definitions/Input" + }, + { + "type": "object", + "properties": { + "variables": { + "type": "object", + "description": "A map of variable names to their values. Keys in the input `value` that are wrapped in `{curly_braces}` will be replaced with the corresponding variable values.", + "additionalProperties": { + "$ref": "#/definitions/Input" + } + } + } + } + ] + }, + "PositionalArgument": { + "description": "A positional input is a value inserted verbatim into the command line.", + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "positional" + ], + "example": "positional" + }, + "value_hint": { + "type": "string", + "description": "An identifier-like hint for the value. This is not part of the command line, but can be used by client configuration and to provide hints to users.", + "example": "file_path" + }, + "is_repeated": { + "type": "boolean", + "description": "Whether the argument can be repeated multiple times in the command line.", + "default": false + } + }, + "anyOf": [ + { + "required": [ + "value_hint" + ] + }, + { + "required": [ + "value" + ] + } + ] + } + ] + }, + "NamedArgument": { + "description": "A command-line `--flag={value}`.", + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "type": "object", + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "named" + ], + "example": "named" + }, + "name": { + "type": "string", + "description": "The flag name, including any leading dashes.", + "example": "--port" + }, + "is_repeated": { + "type": "boolean", + "description": "Whether the argument can be repeated multiple times.", + "default": false + } + } + } + ] + }, + "KeyValueInput": { + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the header or environment variable.", + "example": "SOME_VARIABLE" + } + } + } + ] + }, + "Argument": { + "description": "Warning: Arguments construct command-line parameters that may contain user-provided input. This creates potential command injection risks if clients execute commands in a shell environment. For example, a malicious argument value like ';rm -rf ~/Development' could execute dangerous commands. Clients should prefer non-shell execution methods (e.g., posix_spawn) when possible to eliminate injection risks entirely. Where not possible, clients should obtain consent from users or agents to run the resolved command before execution.", + "anyOf": [ + { + "$ref": "#/definitions/PositionalArgument" + }, + { + "$ref": "#/definitions/NamedArgument" + } + ] + }, + "StdioTransport": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "stdio" + ], + "description": "Transport type", + "example": "stdio" + } + } + }, + "StreamableHttpTransport": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "streamable-http" + ], + "description": "Transport type", + "example": "streamable-http" + }, + "url": { + "type": "string", + "description": "URL template for the streamable-http transport. Variables in {curly_braces} reference argument value_hints, argument names, or environment variable names. After variable substitution, this should produce a valid URI.", + "example": "https://api.example.com/mcp" + }, + "headers": { + "type": "array", + "description": "HTTP headers to include", + "items": { + "$ref": "#/definitions/KeyValueInput" + } + } + } + }, + "SseTransport": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "sse" + ], + "description": "Transport type", + "example": "sse" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Server-Sent Events endpoint URL", + "example": "https://mcp-fs.example.com/sse" + }, + "headers": { + "type": "array", + "description": "HTTP headers to include", + "items": { + "$ref": "#/definitions/KeyValueInput" + } + } + } + }, + "ServerDetail": { + "description": "Schema for a static representation of an MCP server. Used in various contexts related to discovery, installation, and configuration.", + "allOf": [ + { + "$ref": "#/definitions/Server" + }, + { + "type": "object", + "properties": { + "$schema": { + "type": "string", + "format": "uri", + "description": "JSON Schema URI for this server.json format", + "example": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json" + }, + "packages": { + "type": "array", + "items": { + "$ref": "#/definitions/Package" + } + }, + "remotes": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ] + } + }, + "_meta": { + "type": "object", + "description": "Extension metadata using reverse DNS namespacing for vendor-specific data", + "additionalProperties": true, + "properties": { + "io.modelcontextprotocol.registry/publisher-provided": { + "type": "object", + "description": "Publisher-provided metadata for downstream registries", + "additionalProperties": true + }, + "io.modelcontextprotocol.registry/official": { + "type": "object", + "description": "Official MCP registry metadata (read-only, added by registry)", + "additionalProperties": true + } + } + } + } + } + ] + } + } +} \ No newline at end of file diff --git a/internal/validators/schemas/2025-09-16.json b/internal/validators/schemas/2025-09-16.json new file mode 100644 index 000000000..8766b2a49 --- /dev/null +++ b/internal/validators/schemas/2025-09-16.json @@ -0,0 +1,478 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json", + "title": "MCP Server Detail", + "$ref": "#/definitions/ServerDetail", + "definitions": { + "Repository": { + "type": "object", + "description": "Repository metadata for the MCP server source code. Enables users and security experts to inspect the code, improving transparency.", + "required": [ + "url", + "source" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Repository URL for browsing source code. Should support both web browsing and git clone operations.", + "example": "https://github.com/modelcontextprotocol/servers" + }, + "source": { + "type": "string", + "description": "Repository hosting service identifier. Used by registries to determine validation and API access methods.", + "example": "github" + }, + "id": { + "type": "string", + "description": "Repository identifier from the hosting service (e.g., GitHub repo ID). Owned and determined by the source forge. Should remain stable across repository renames and may be used to detect repository resurrection attacks - if a repository is deleted and recreated, the ID should change. For GitHub, use: gh api repos// --jq '.id'", + "example": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9" + }, + "subfolder": { + "type": "string", + "description": "Optional relative path from repository root to the server location within a monorepo or nested package structure. Must be a clean relative path.", + "example": "src/everything" + } + } + }, + "Server": { + "type": "object", + "required": [ + "name", + "description", + "version" + ], + "properties": { + "name": { + "type": "string", + "description": "Server name in reverse-DNS format. Must contain exactly one forward slash separating namespace from server name.", + "example": "io.github.user/weather", + "pattern": "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$", + "minLength": 3, + "maxLength": 200 + }, + "description": { + "type": "string", + "description": "Clear human-readable explanation of server functionality. Should focus on capabilities, not implementation details.", + "example": "MCP server providing weather data and forecasts via OpenWeatherMap API", + "minLength": 1, + "maxLength": 100 + }, + "status": { + "type": "string", + "enum": ["active", "deprecated", "deleted"], + "default": "active", + "description": "Server lifecycle status. 'deprecated' indicates the server is no longer recommended for new usage. 'deleted' indicates the server should never be installed and existing installations should be uninstalled - this is rare, and usually indicates malware or a legal takedown." + }, + "repository": { + "$ref": "#/definitions/Repository", + "description": "Optional repository metadata for the MCP server source code. Recommended for transparency and security inspection." + }, + "version": { + "type": "string", + "maxLength": 255, + "example": "1.0.2", + "description": "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification. Non-semantic versions are allowed but may not sort predictably. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*')." + }, + "websiteUrl": { + "type": "string", + "format": "uri", + "description": "Optional URL to the server's homepage, documentation, or project website. This provides a central link for users to learn more about the server. Particularly useful when the server has custom installation instructions or setup requirements.", + "example": "https://modelcontextprotocol.io/examples" + } + } + }, + "Package": { + "type": "object", + "additionalProperties": false, + "required": [ + "registryType", + "identifier", + "version", + "transport" + ], + "properties": { + "registryType": { + "type": "string", + "description": "Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb')", + "examples": ["npm", "pypi", "oci", "nuget", "mcpb"] + }, + "registryBaseUrl": { + "type": "string", + "format": "uri", + "description": "Base URL of the package registry", + "examples": ["https://registry.npmjs.org", "https://pypi.org", "https://docker.io", "https://api.nuget.org", "https://github.com", "https://gitlab.com"] + }, + "identifier": { + "type": "string", + "description": "Package identifier - either a package name (for registries) or URL (for direct downloads)", + "examples": ["@modelcontextprotocol/server-brave-search", "https://github.com/example/releases/download/v1.0.0/package.mcpb"] + }, + "version": { + "type": "string", + "description": "Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*').", + "not": { + "const": "latest" + }, + "example": "1.0.2", + "minLength": 1 + }, + "fileSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "SHA-256 hash of the package file for integrity verification. Required for MCPB packages and optional for other package types. Authors are responsible for generating correct SHA-256 hashes when creating server.json. If present, MCP clients must validate the downloaded file matches the hash before running packages to ensure file integrity.", + "example": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce" + }, + "runtimeHint": { + "type": "string", + "description": "A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtimeArguments` are present.", + "examples": [ + "npx", + "uvx", + "docker", + "dnx" + ] + }, + "transport": { + "anyOf": [ + { + "$ref": "#/definitions/StdioTransport" + }, + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ], + "description": "Transport protocol configuration for the package" + }, + "runtimeArguments": { + "type": "array", + "description": "A list of arguments to be passed to the package's runtime command (such as docker or npx). The `runtimeHint` field should be provided when `runtimeArguments` are present.", + "items": { + "$ref": "#/definitions/Argument" + } + }, + "packageArguments": { + "type": "array", + "description": "A list of arguments to be passed to the package's binary.", + "items": { + "$ref": "#/definitions/Argument" + } + }, + "environmentVariables": { + "type": "array", + "description": "A mapping of environment variables to be set when running the package.", + "items": { + "$ref": "#/definitions/KeyValueInput" + } + } + } + }, + "Input": { + "type": "object", + "properties": { + "description": { + "description": "A description of the input, which clients can use to provide context to the user.", + "type": "string" + }, + "isRequired": { + "type": "boolean", + "default": false + }, + "format": { + "type": "string", + "description": "Specifies the input format. Supported values include `filepath`, which should be interpreted as a file on the user's filesystem.\n\nWhen the input is converted to a string, booleans should be represented by the strings \"true\" and \"false\", and numbers should be represented as decimal values.", + "enum": [ + "string", + "number", + "boolean", + "filepath" + ], + "default": "string" + }, + "value": { + "type": "string", + "description": "The default value for the input. If this is not set, the user may be prompted to provide a value. If a value is set, it should not be configurable by end users.\n\nIdentifiers wrapped in `{curly_braces}` will be replaced with the corresponding properties from the input `variables` map. If an identifier in braces is not found in `variables`, or if `variables` is not provided, the `{curly_braces}` substring should remain unchanged.\n" + }, + "isSecret": { + "type": "boolean", + "description": "Indicates whether the input is a secret value (e.g., password, token). If true, clients should handle the value securely.", + "default": false + }, + "default": { + "type": "string", + "description": "The default value for the input." + }, + "choices": { + "type": "array", + "description": "A list of possible values for the input. If provided, the user must select one of these values.", + "items": { + "type": "string" + }, + "example": [] + } + } + }, + "InputWithVariables": { + "allOf": [ + { + "$ref": "#/definitions/Input" + }, + { + "type": "object", + "properties": { + "variables": { + "type": "object", + "description": "A map of variable names to their values. Keys in the input `value` that are wrapped in `{curly_braces}` will be replaced with the corresponding variable values.", + "additionalProperties": { + "$ref": "#/definitions/Input" + } + } + } + } + ] + }, + "PositionalArgument": { + "description": "A positional input is a value inserted verbatim into the command line.", + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "positional" + ], + "example": "positional" + }, + "valueHint": { + "type": "string", + "description": "An identifier-like hint for the value. This is not part of the command line, but can be used by client configuration and to provide hints to users.", + "example": "file_path" + }, + "isRepeated": { + "type": "boolean", + "description": "Whether the argument can be repeated multiple times in the command line.", + "default": false + } + }, + "anyOf": [ + { + "required": [ + "valueHint" + ] + }, + { + "required": [ + "value" + ] + } + ] + } + ] + }, + "NamedArgument": { + "description": "A command-line `--flag={value}`.", + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "type": "object", + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "named" + ], + "example": "named" + }, + "name": { + "type": "string", + "description": "The flag name, including any leading dashes.", + "example": "--port" + }, + "isRepeated": { + "type": "boolean", + "description": "Whether the argument can be repeated multiple times.", + "default": false + } + } + } + ] + }, + "KeyValueInput": { + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the header or environment variable.", + "example": "SOME_VARIABLE" + } + } + } + ] + }, + "Argument": { + "description": "Warning: Arguments construct command-line parameters that may contain user-provided input. This creates potential command injection risks if clients execute commands in a shell environment. For example, a malicious argument value like ';rm -rf ~/Development' could execute dangerous commands. Clients should prefer non-shell execution methods (e.g., posix_spawn) when possible to eliminate injection risks entirely. Where not possible, clients should obtain consent from users or agents to run the resolved command before execution.", + "anyOf": [ + { + "$ref": "#/definitions/PositionalArgument" + }, + { + "$ref": "#/definitions/NamedArgument" + } + ] + }, + "StdioTransport": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "stdio" + ], + "description": "Transport type", + "example": "stdio" + } + } + }, + "StreamableHttpTransport": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "streamable-http" + ], + "description": "Transport type", + "example": "streamable-http" + }, + "url": { + "type": "string", + "description": "URL template for the streamable-http transport. Variables in {curly_braces} reference argument valueHints, argument names, or environment variable names. After variable substitution, this should produce a valid URI.", + "example": "https://api.example.com/mcp" + }, + "headers": { + "type": "array", + "description": "HTTP headers to include", + "items": { + "$ref": "#/definitions/KeyValueInput" + } + } + } + }, + "SseTransport": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "sse" + ], + "description": "Transport type", + "example": "sse" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Server-Sent Events endpoint URL", + "example": "https://mcp-fs.example.com/sse" + }, + "headers": { + "type": "array", + "description": "HTTP headers to include", + "items": { + "$ref": "#/definitions/KeyValueInput" + } + } + } + }, + "ServerDetail": { + "description": "Schema for a static representation of an MCP server. Used in various contexts related to discovery, installation, and configuration.", + "allOf": [ + { + "$ref": "#/definitions/Server" + }, + { + "type": "object", + "properties": { + "$schema": { + "type": "string", + "format": "uri", + "description": "JSON Schema URI for this server.json format", + "example": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json" + }, + "packages": { + "type": "array", + "items": { + "$ref": "#/definitions/Package" + } + }, + "remotes": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ] + } + }, + "_meta": { + "type": "object", + "description": "Extension metadata using reverse DNS namespacing for vendor-specific data", + "additionalProperties": true, + "properties": { + "io.modelcontextprotocol.registry/publisher-provided": { + "type": "object", + "description": "Publisher-provided metadata for downstream registries", + "additionalProperties": true + }, + "io.modelcontextprotocol.registry/official": { + "type": "object", + "description": "Official MCP registry metadata (read-only, added by registry)", + "additionalProperties": true + } + } + } + } + } + ] + } + } +} diff --git a/internal/validators/schemas/2025-09-29.json b/internal/validators/schemas/2025-09-29.json new file mode 100644 index 000000000..bcf5ba5af --- /dev/null +++ b/internal/validators/schemas/2025-09-29.json @@ -0,0 +1,483 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json", + "title": "MCP Server Detail", + "$ref": "#/definitions/ServerDetail", + "definitions": { + "Repository": { + "type": "object", + "description": "Repository metadata for the MCP server source code. Enables users and security experts to inspect the code, improving transparency.", + "required": [ + "url", + "source" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Repository URL for browsing source code. Should support both web browsing and git clone operations.", + "example": "https://github.com/modelcontextprotocol/servers" + }, + "source": { + "type": "string", + "description": "Repository hosting service identifier. Used by registries to determine validation and API access methods.", + "example": "github" + }, + "id": { + "type": "string", + "description": "Repository identifier from the hosting service (e.g., GitHub repo ID). Owned and determined by the source forge. Should remain stable across repository renames and may be used to detect repository resurrection attacks - if a repository is deleted and recreated, the ID should change. For GitHub, use: gh api repos// --jq '.id'", + "example": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9" + }, + "subfolder": { + "type": "string", + "description": "Optional relative path from repository root to the server location within a monorepo or nested package structure. Must be a clean relative path.", + "example": "src/everything" + } + } + }, + "Server": { + "type": "object", + "required": [ + "name", + "description", + "version" + ], + "properties": { + "name": { + "type": "string", + "description": "Server name in reverse-DNS format. Must contain exactly one forward slash separating namespace from server name.", + "example": "io.github.user/weather", + "pattern": "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$", + "minLength": 3, + "maxLength": 200 + }, + "description": { + "type": "string", + "description": "Clear human-readable explanation of server functionality. Should focus on capabilities, not implementation details.", + "example": "MCP server providing weather data and forecasts via OpenWeatherMap API", + "minLength": 1, + "maxLength": 100 + }, + "repository": { + "$ref": "#/definitions/Repository", + "description": "Optional repository metadata for the MCP server source code. Recommended for transparency and security inspection." + }, + "version": { + "type": "string", + "maxLength": 255, + "example": "1.0.2", + "description": "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification. Non-semantic versions are allowed but may not sort predictably. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*')." + }, + "websiteUrl": { + "type": "string", + "format": "uri", + "description": "Optional URL to the server's homepage, documentation, or project website. This provides a central link for users to learn more about the server. Particularly useful when the server has custom installation instructions or setup requirements.", + "example": "https://modelcontextprotocol.io/examples" + } + } + }, + "Package": { + "type": "object", + "additionalProperties": false, + "required": [ + "registryType", + "identifier", + "version", + "transport" + ], + "properties": { + "registryType": { + "type": "string", + "description": "Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb')", + "examples": [ + "npm", + "pypi", + "oci", + "nuget", + "mcpb" + ] + }, + "registryBaseUrl": { + "type": "string", + "format": "uri", + "description": "Base URL of the package registry", + "examples": [ + "https://registry.npmjs.org", + "https://pypi.org", + "https://docker.io", + "https://api.nuget.org", + "https://github.com", + "https://gitlab.com" + ] + }, + "identifier": { + "type": "string", + "description": "Package identifier - either a package name (for registries) or URL (for direct downloads)", + "examples": [ + "@modelcontextprotocol/server-brave-search", + "https://github.com/example/releases/download/v1.0.0/package.mcpb" + ] + }, + "version": { + "type": "string", + "description": "Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*').", + "not": { + "const": "latest" + }, + "example": "1.0.2", + "minLength": 1 + }, + "fileSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "SHA-256 hash of the package file for integrity verification. Required for MCPB packages and optional for other package types. Authors are responsible for generating correct SHA-256 hashes when creating server.json. If present, MCP clients must validate the downloaded file matches the hash before running packages to ensure file integrity.", + "example": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce" + }, + "runtimeHint": { + "type": "string", + "description": "A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtimeArguments` are present.", + "examples": [ + "npx", + "uvx", + "docker", + "dnx" + ] + }, + "transport": { + "anyOf": [ + { + "$ref": "#/definitions/StdioTransport" + }, + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ], + "description": "Transport protocol configuration for the package" + }, + "runtimeArguments": { + "type": "array", + "description": "A list of arguments to be passed to the package's runtime command (such as docker or npx). The `runtimeHint` field should be provided when `runtimeArguments` are present.", + "items": { + "$ref": "#/definitions/Argument" + } + }, + "packageArguments": { + "type": "array", + "description": "A list of arguments to be passed to the package's binary.", + "items": { + "$ref": "#/definitions/Argument" + } + }, + "environmentVariables": { + "type": "array", + "description": "A mapping of environment variables to be set when running the package.", + "items": { + "$ref": "#/definitions/KeyValueInput" + } + } + } + }, + "Input": { + "type": "object", + "properties": { + "description": { + "description": "A description of the input, which clients can use to provide context to the user.", + "type": "string" + }, + "isRequired": { + "type": "boolean", + "default": false + }, + "format": { + "type": "string", + "description": "Specifies the input format. Supported values include `filepath`, which should be interpreted as a file on the user's filesystem.\n\nWhen the input is converted to a string, booleans should be represented by the strings \"true\" and \"false\", and numbers should be represented as decimal values.", + "enum": [ + "string", + "number", + "boolean", + "filepath" + ], + "default": "string" + }, + "value": { + "type": "string", + "description": "The default value for the input. If this is not set, the user may be prompted to provide a value. If a value is set, it should not be configurable by end users.\n\nIdentifiers wrapped in `{curly_braces}` will be replaced with the corresponding properties from the input `variables` map. If an identifier in braces is not found in `variables`, or if `variables` is not provided, the `{curly_braces}` substring should remain unchanged.\n" + }, + "isSecret": { + "type": "boolean", + "description": "Indicates whether the input is a secret value (e.g., password, token). If true, clients should handle the value securely.", + "default": false + }, + "default": { + "type": "string", + "description": "The default value for the input." + }, + "choices": { + "type": "array", + "description": "A list of possible values for the input. If provided, the user must select one of these values.", + "items": { + "type": "string" + }, + "example": [] + } + } + }, + "InputWithVariables": { + "allOf": [ + { + "$ref": "#/definitions/Input" + }, + { + "type": "object", + "properties": { + "variables": { + "type": "object", + "description": "A map of variable names to their values. Keys in the input `value` that are wrapped in `{curly_braces}` will be replaced with the corresponding variable values.", + "additionalProperties": { + "$ref": "#/definitions/Input" + } + } + } + } + ] + }, + "PositionalArgument": { + "description": "A positional input is a value inserted verbatim into the command line.", + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "positional" + ], + "example": "positional" + }, + "valueHint": { + "type": "string", + "description": "An identifier-like hint for the value. This is not part of the command line, but can be used by client configuration and to provide hints to users.", + "example": "file_path" + }, + "isRepeated": { + "type": "boolean", + "description": "Whether the argument can be repeated multiple times in the command line.", + "default": false + } + }, + "anyOf": [ + { + "required": [ + "valueHint" + ] + }, + { + "required": [ + "value" + ] + } + ] + } + ] + }, + "NamedArgument": { + "description": "A command-line `--flag={value}`.", + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "type": "object", + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "named" + ], + "example": "named" + }, + "name": { + "type": "string", + "description": "The flag name, including any leading dashes.", + "example": "--port" + }, + "isRepeated": { + "type": "boolean", + "description": "Whether the argument can be repeated multiple times.", + "default": false + } + } + } + ] + }, + "KeyValueInput": { + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the header or environment variable.", + "example": "SOME_VARIABLE" + } + } + } + ] + }, + "Argument": { + "description": "Warning: Arguments construct command-line parameters that may contain user-provided input. This creates potential command injection risks if clients execute commands in a shell environment. For example, a malicious argument value like ';rm -rf ~/Development' could execute dangerous commands. Clients should prefer non-shell execution methods (e.g., posix_spawn) when possible to eliminate injection risks entirely. Where not possible, clients should obtain consent from users or agents to run the resolved command before execution.", + "anyOf": [ + { + "$ref": "#/definitions/PositionalArgument" + }, + { + "$ref": "#/definitions/NamedArgument" + } + ] + }, + "StdioTransport": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "stdio" + ], + "description": "Transport type", + "example": "stdio" + } + } + }, + "StreamableHttpTransport": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "streamable-http" + ], + "description": "Transport type", + "example": "streamable-http" + }, + "url": { + "type": "string", + "description": "URL template for the streamable-http transport. Variables in {curly_braces} reference argument valueHints, argument names, or environment variable names. After variable substitution, this should produce a valid URI.", + "example": "https://api.example.com/mcp" + }, + "headers": { + "type": "array", + "description": "HTTP headers to include", + "items": { + "$ref": "#/definitions/KeyValueInput" + } + } + } + }, + "SseTransport": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "sse" + ], + "description": "Transport type", + "example": "sse" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Server-Sent Events endpoint URL", + "example": "https://mcp-fs.example.com/sse" + }, + "headers": { + "type": "array", + "description": "HTTP headers to include", + "items": { + "$ref": "#/definitions/KeyValueInput" + } + } + } + }, + "ServerDetail": { + "description": "Schema for a static representation of an MCP server. Used in various contexts related to discovery, installation, and configuration.", + "allOf": [ + { + "$ref": "#/definitions/Server" + }, + { + "type": "object", + "properties": { + "$schema": { + "type": "string", + "format": "uri", + "description": "JSON Schema URI for this server.json format", + "example": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json" + }, + "packages": { + "type": "array", + "items": { + "$ref": "#/definitions/Package" + } + }, + "remotes": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ] + } + }, + "_meta": { + "type": "object", + "description": "Extension metadata using reverse DNS namespacing for vendor-specific data", + "additionalProperties": true, + "properties": { + "io.modelcontextprotocol.registry/publisher-provided": { + "type": "object", + "description": "Publisher-provided metadata for downstream registries", + "additionalProperties": true + } + } + } + } + } + ] + } + } +} \ No newline at end of file diff --git a/internal/validators/schemas/2025-10-11.json b/internal/validators/schemas/2025-10-11.json new file mode 100644 index 000000000..8022347ae --- /dev/null +++ b/internal/validators/schemas/2025-10-11.json @@ -0,0 +1,549 @@ +{ + "$comment": "This file is auto-generated from docs/reference/api/openapi.yaml. Do not edit manually. Run 'make generate-schema' to update.", + "$id": "https://static.modelcontextprotocol.io/schemas/2025-10-11/server.schema.json", + "$ref": "#/definitions/ServerDetail", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Argument": { + "anyOf": [ + { + "$ref": "#/definitions/PositionalArgument" + }, + { + "$ref": "#/definitions/NamedArgument" + } + ], + "description": "Warning: Arguments construct command-line parameters that may contain user-provided input. This creates potential command injection risks if clients execute commands in a shell environment. For example, a malicious argument value like ';rm -rf ~/Development' could execute dangerous commands. Clients should prefer non-shell execution methods (e.g., posix_spawn) when possible to eliminate injection risks entirely. Where not possible, clients should obtain consent from users or agents to run the resolved command before execution." + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic. Must be one of: image/png, image/jpeg, image/jpg, image/svg+xml, image/webp.", + "enum": [ + "image/png", + "image/jpeg", + "image/jpg", + "image/svg+xml", + "image/webp" + ], + "example": "image/png", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used. Each string should be in WxH format (e.g., '48x48', '96x96') or 'any' for scalable formats like SVG. If not provided, the client should assume that the icon can be used at any size.", + "examples": [ + [ + "48x48", + "96x96" + ], + [ + "any" + ] + ], + "items": { + "pattern": "^(\\d+x\\d+|any)$", + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. Must be an HTTPS URL. Consumers SHOULD take steps to ensure URLs serving icons are from the same domain as the server or a trusted domain. Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain executable JavaScript.", + "example": "https://example.com/icon.png", + "format": "uri", + "maxLength": 255, + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. 'light' indicates the icon is designed to be used with a light background, and 'dark' indicates the icon is designed to be used with a dark background. If not provided, the client should assume the icon can be used with any theme.", + "enum": [ + "light", + "dark" + ], + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" + }, + "Input": { + "properties": { + "choices": { + "description": "A list of possible values for the input. If provided, the user must select one of these values.", + "example": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "default": { + "description": "The default value for the input. This should be a valid value for the input. If you want to provide input examples or guidance, use the `placeholder` field instead.", + "type": "string" + }, + "description": { + "description": "A description of the input, which clients can use to provide context to the user.", + "type": "string" + }, + "format": { + "default": "string", + "description": "Specifies the input format. Supported values include `filepath`, which should be interpreted as a file on the user's filesystem.\n\nWhen the input is converted to a string, booleans should be represented by the strings \"true\" and \"false\", and numbers should be represented as decimal values.", + "enum": [ + "string", + "number", + "boolean", + "filepath" + ], + "type": "string" + }, + "isRequired": { + "default": false, + "type": "boolean" + }, + "isSecret": { + "default": false, + "description": "Indicates whether the input is a secret value (e.g., password, token). If true, clients should handle the value securely.", + "type": "boolean" + }, + "placeholder": { + "description": "A placeholder for the input to be displaying during configuration. This is used to provide examples or guidance about the expected form or content of the input.", + "type": "string" + }, + "value": { + "description": "The value for the input. If this is not set, the user may be prompted to provide a value. If a value is set, it should not be configurable by end users.\n\nIdentifiers wrapped in `{curly_braces}` will be replaced with the corresponding properties from the input `variables` map. If an identifier in braces is not found in `variables`, or if `variables` is not provided, the `{curly_braces}` substring should remain unchanged.\n", + "type": "string" + } + }, + "type": "object" + }, + "InputWithVariables": { + "allOf": [ + { + "$ref": "#/definitions/Input" + }, + { + "properties": { + "variables": { + "additionalProperties": { + "$ref": "#/definitions/Input" + }, + "description": "A map of variable names to their values. Keys in the input `value` that are wrapped in `{curly_braces}` will be replaced with the corresponding variable values.", + "type": "object" + } + }, + "type": "object" + } + ] + }, + "KeyValueInput": { + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "properties": { + "name": { + "description": "Name of the header or environment variable.", + "example": "SOME_VARIABLE", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ] + }, + "NamedArgument": { + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "properties": { + "isRepeated": { + "default": false, + "description": "Whether the argument can be repeated multiple times.", + "type": "boolean" + }, + "name": { + "description": "The flag name, including any leading dashes.", + "example": "--port", + "type": "string" + }, + "type": { + "enum": [ + "named" + ], + "example": "named", + "type": "string" + } + }, + "required": [ + "type", + "name" + ], + "type": "object" + } + ], + "description": "A command-line `--flag={value}`." + }, + "Package": { + "properties": { + "environmentVariables": { + "description": "A mapping of environment variables to be set when running the package.", + "items": { + "$ref": "#/definitions/KeyValueInput" + }, + "type": "array" + }, + "fileSha256": { + "description": "SHA-256 hash of the package file for integrity verification. Required for MCPB packages and optional for other package types. Authors are responsible for generating correct SHA-256 hashes when creating server.json. If present, MCP clients must validate the downloaded file matches the hash before running packages to ensure file integrity.", + "example": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "identifier": { + "description": "Package identifier - either a package name (for registries) or URL (for direct downloads)", + "examples": [ + "@modelcontextprotocol/server-brave-search", + "https://github.com/example/releases/download/v1.0.0/package.mcpb" + ], + "type": "string" + }, + "packageArguments": { + "description": "A list of arguments to be passed to the package's binary.", + "items": { + "$ref": "#/definitions/Argument" + }, + "type": "array" + }, + "registryBaseUrl": { + "description": "Base URL of the package registry", + "examples": [ + "https://registry.npmjs.org", + "https://pypi.org", + "https://docker.io", + "https://api.nuget.org", + "https://github.com", + "https://gitlab.com" + ], + "format": "uri", + "type": "string" + }, + "registryType": { + "description": "Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb')", + "examples": [ + "npm", + "pypi", + "oci", + "nuget", + "mcpb" + ], + "type": "string" + }, + "runtimeArguments": { + "description": "A list of arguments to be passed to the package's runtime command (such as docker or npx). The `runtimeHint` field should be provided when `runtimeArguments` are present.", + "items": { + "$ref": "#/definitions/Argument" + }, + "type": "array" + }, + "runtimeHint": { + "description": "A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtimeArguments` are present.", + "examples": [ + "npx", + "uvx", + "docker", + "dnx" + ], + "type": "string" + }, + "transport": { + "anyOf": [ + { + "$ref": "#/definitions/StdioTransport" + }, + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ], + "description": "Transport protocol configuration for the package" + }, + "version": { + "description": "Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '\u003e=1.2.3', '1.x', '1.*').", + "example": "1.0.2", + "minLength": 1, + "not": { + "const": "latest" + }, + "type": "string" + } + }, + "required": [ + "registryType", + "identifier", + "transport" + ], + "type": "object" + }, + "PositionalArgument": { + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "anyOf": [ + { + "required": [ + "valueHint" + ] + }, + { + "required": [ + "value" + ] + } + ], + "properties": { + "isRepeated": { + "default": false, + "description": "Whether the argument can be repeated multiple times in the command line.", + "type": "boolean" + }, + "type": { + "enum": [ + "positional" + ], + "example": "positional", + "type": "string" + }, + "valueHint": { + "description": "An identifier for the positional argument. It is not part of the command line. It may be used by client configuration as a label identifying the argument. It is also used to identify the value in transport URL variable substitution.", + "example": "file_path", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ], + "description": "A positional input is a value inserted verbatim into the command line." + }, + "Repository": { + "description": "Repository metadata for the MCP server source code. Enables users and security experts to inspect the code, improving transparency.", + "properties": { + "id": { + "description": "Repository identifier from the hosting service (e.g., GitHub repo ID). Owned and determined by the source forge. Should remain stable across repository renames and may be used to detect repository resurrection attacks - if a repository is deleted and recreated, the ID should change. For GitHub, use: gh api repos/\u003cowner\u003e/\u003crepo\u003e --jq '.id'", + "example": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9", + "type": "string" + }, + "source": { + "description": "Repository hosting service identifier. Used by registries to determine validation and API access methods.", + "example": "github", + "type": "string" + }, + "subfolder": { + "description": "Optional relative path from repository root to the server location within a monorepo or nested package structure. Must be a clean relative path.", + "example": "src/everything", + "type": "string" + }, + "url": { + "description": "Repository URL for browsing source code. Should support both web browsing and git clone operations.", + "example": "https://github.com/modelcontextprotocol/servers", + "format": "uri", + "type": "string" + } + }, + "required": [ + "url", + "source" + ], + "type": "object" + }, + "ServerDetail": { + "description": "Schema for a static representation of an MCP server. Used in various contexts related to discovery, installation, and configuration.", + "properties": { + "$schema": { + "description": "JSON Schema URI for this server.json format", + "example": "https://static.modelcontextprotocol.io/schemas/2025-10-11/server.schema.json", + "format": "uri", + "type": "string" + }, + "_meta": { + "description": "Extension metadata using reverse DNS namespacing for vendor-specific data", + "properties": { + "io.modelcontextprotocol.registry/publisher-provided": { + "additionalProperties": true, + "description": "Publisher-provided metadata for downstream registries", + "example": { + "buildInfo": { + "commit": "abc123def456", + "pipelineId": "build-789", + "timestamp": "2023-12-01T10:30:00Z" + }, + "tool": "publisher-cli", + "version": "1.2.3" + }, + "type": "object" + } + }, + "type": "object" + }, + "description": { + "description": "Clear human-readable explanation of server functionality. Should focus on capabilities, not implementation details.", + "example": "MCP server providing weather data and forecasts via OpenWeatherMap API", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface. Clients that support rendering icons MUST support at least the following MIME types: image/png and image/jpeg (safe, universal compatibility). Clients SHOULD also support: image/svg+xml (scalable but requires security precautions) and image/webp (modern, efficient format).", + "items": { + "$ref": "#/definitions/Icon" + }, + "type": "array" + }, + "name": { + "description": "Server name in reverse-DNS format. Must contain exactly one forward slash separating namespace from server name.", + "example": "io.github.user/weather", + "maxLength": 200, + "minLength": 3, + "pattern": "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$", + "type": "string" + }, + "packages": { + "items": { + "$ref": "#/definitions/Package" + }, + "type": "array" + }, + "remotes": { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ] + }, + "type": "array" + }, + "repository": { + "$ref": "#/definitions/Repository", + "description": "Optional repository metadata for the MCP server source code. Recommended for transparency and security inspection." + }, + "title": { + "description": "Optional human-readable title or display name for the MCP server. MCP subregistries or clients MAY choose to use this for display purposes.", + "example": "Weather API", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "version": { + "description": "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification. Non-semantic versions are allowed but may not sort predictably. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '\u003e=1.2.3', '1.x', '1.*').", + "example": "1.0.2", + "maxLength": 255, + "type": "string" + }, + "websiteUrl": { + "description": "Optional URL to the server's homepage, documentation, or project website. This provides a central link for users to learn more about the server. Particularly useful when the server has custom installation instructions or setup requirements.", + "example": "https://modelcontextprotocol.io/examples", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "description", + "version" + ], + "type": "object" + }, + "SseTransport": { + "properties": { + "headers": { + "description": "HTTP headers to include", + "items": { + "$ref": "#/definitions/KeyValueInput" + }, + "type": "array" + }, + "type": { + "description": "Transport type", + "enum": [ + "sse" + ], + "example": "sse", + "type": "string" + }, + "url": { + "description": "Server-Sent Events endpoint URL", + "example": "https://mcp-fs.example.com/sse", + "format": "uri", + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "type": "object" + }, + "StdioTransport": { + "properties": { + "type": { + "description": "Transport type", + "enum": [ + "stdio" + ], + "example": "stdio", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "StreamableHttpTransport": { + "properties": { + "headers": { + "description": "HTTP headers to include", + "items": { + "$ref": "#/definitions/KeyValueInput" + }, + "type": "array" + }, + "type": { + "description": "Transport type", + "enum": [ + "streamable-http" + ], + "example": "streamable-http", + "type": "string" + }, + "url": { + "description": "URL template for the streamable-http transport. Variables in {curly_braces} reference argument valueHints, argument names, or environment variable names. After variable substitution, this should produce a valid URI.", + "example": "https://api.example.com/mcp", + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "type": "object" + } + }, + "title": "server.json defining a Model Context Protocol (MCP) server" +} diff --git a/internal/validators/schemas/2025-10-17.json b/internal/validators/schemas/2025-10-17.json new file mode 100644 index 000000000..59967b5a3 --- /dev/null +++ b/internal/validators/schemas/2025-10-17.json @@ -0,0 +1,549 @@ +{ + "$comment": "This file is auto-generated from docs/reference/api/openapi.yaml. Do not edit manually. Run 'make generate-schema' to update.", + "$id": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "$ref": "#/definitions/ServerDetail", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Argument": { + "anyOf": [ + { + "$ref": "#/definitions/PositionalArgument" + }, + { + "$ref": "#/definitions/NamedArgument" + } + ], + "description": "Warning: Arguments construct command-line parameters that may contain user-provided input. This creates potential command injection risks if clients execute commands in a shell environment. For example, a malicious argument value like ';rm -rf ~/Development' could execute dangerous commands. Clients should prefer non-shell execution methods (e.g., posix_spawn) when possible to eliminate injection risks entirely. Where not possible, clients should obtain consent from users or agents to run the resolved command before execution." + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic. Must be one of: image/png, image/jpeg, image/jpg, image/svg+xml, image/webp.", + "enum": [ + "image/png", + "image/jpeg", + "image/jpg", + "image/svg+xml", + "image/webp" + ], + "example": "image/png", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used. Each string should be in WxH format (e.g., '48x48', '96x96') or 'any' for scalable formats like SVG. If not provided, the client should assume that the icon can be used at any size.", + "examples": [ + [ + "48x48", + "96x96" + ], + [ + "any" + ] + ], + "items": { + "pattern": "^(\\d+x\\d+|any)$", + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. Must be an HTTPS URL. Consumers SHOULD take steps to ensure URLs serving icons are from the same domain as the server or a trusted domain. Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain executable JavaScript.", + "example": "https://example.com/icon.png", + "format": "uri", + "maxLength": 255, + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. 'light' indicates the icon is designed to be used with a light background, and 'dark' indicates the icon is designed to be used with a dark background. If not provided, the client should assume the icon can be used with any theme.", + "enum": [ + "light", + "dark" + ], + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" + }, + "Input": { + "properties": { + "choices": { + "description": "A list of possible values for the input. If provided, the user must select one of these values.", + "example": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "default": { + "description": "The default value for the input. This should be a valid value for the input. If you want to provide input examples or guidance, use the `placeholder` field instead.", + "type": "string" + }, + "description": { + "description": "A description of the input, which clients can use to provide context to the user.", + "type": "string" + }, + "format": { + "default": "string", + "description": "Specifies the input format. Supported values include `filepath`, which should be interpreted as a file on the user's filesystem.\n\nWhen the input is converted to a string, booleans should be represented by the strings \"true\" and \"false\", and numbers should be represented as decimal values.", + "enum": [ + "string", + "number", + "boolean", + "filepath" + ], + "type": "string" + }, + "isRequired": { + "default": false, + "type": "boolean" + }, + "isSecret": { + "default": false, + "description": "Indicates whether the input is a secret value (e.g., password, token). If true, clients should handle the value securely.", + "type": "boolean" + }, + "placeholder": { + "description": "A placeholder for the input to be displaying during configuration. This is used to provide examples or guidance about the expected form or content of the input.", + "type": "string" + }, + "value": { + "description": "The value for the input. If this is not set, the user may be prompted to provide a value. If a value is set, it should not be configurable by end users.\n\nIdentifiers wrapped in `{curly_braces}` will be replaced with the corresponding properties from the input `variables` map. If an identifier in braces is not found in `variables`, or if `variables` is not provided, the `{curly_braces}` substring should remain unchanged.\n", + "type": "string" + } + }, + "type": "object" + }, + "InputWithVariables": { + "allOf": [ + { + "$ref": "#/definitions/Input" + }, + { + "properties": { + "variables": { + "additionalProperties": { + "$ref": "#/definitions/Input" + }, + "description": "A map of variable names to their values. Keys in the input `value` that are wrapped in `{curly_braces}` will be replaced with the corresponding variable values.", + "type": "object" + } + }, + "type": "object" + } + ] + }, + "KeyValueInput": { + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "properties": { + "name": { + "description": "Name of the header or environment variable.", + "example": "SOME_VARIABLE", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ] + }, + "NamedArgument": { + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "properties": { + "isRepeated": { + "default": false, + "description": "Whether the argument can be repeated multiple times.", + "type": "boolean" + }, + "name": { + "description": "The flag name, including any leading dashes.", + "example": "--port", + "type": "string" + }, + "type": { + "enum": [ + "named" + ], + "example": "named", + "type": "string" + } + }, + "required": [ + "type", + "name" + ], + "type": "object" + } + ], + "description": "A command-line `--flag={value}`." + }, + "Package": { + "properties": { + "environmentVariables": { + "description": "A mapping of environment variables to be set when running the package.", + "items": { + "$ref": "#/definitions/KeyValueInput" + }, + "type": "array" + }, + "fileSha256": { + "description": "SHA-256 hash of the package file for integrity verification. Required for MCPB packages and optional for other package types. Authors are responsible for generating correct SHA-256 hashes when creating server.json. If present, MCP clients must validate the downloaded file matches the hash before running packages to ensure file integrity.", + "example": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "identifier": { + "description": "Package identifier - either a package name (for registries) or URL (for direct downloads)", + "examples": [ + "@modelcontextprotocol/server-brave-search", + "https://github.com/example/releases/download/v1.0.0/package.mcpb" + ], + "type": "string" + }, + "packageArguments": { + "description": "A list of arguments to be passed to the package's binary.", + "items": { + "$ref": "#/definitions/Argument" + }, + "type": "array" + }, + "registryBaseUrl": { + "description": "Base URL of the package registry", + "examples": [ + "https://registry.npmjs.org", + "https://pypi.org", + "https://docker.io", + "https://api.nuget.org", + "https://github.com", + "https://gitlab.com" + ], + "format": "uri", + "type": "string" + }, + "registryType": { + "description": "Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb')", + "examples": [ + "npm", + "pypi", + "oci", + "nuget", + "mcpb" + ], + "type": "string" + }, + "runtimeArguments": { + "description": "A list of arguments to be passed to the package's runtime command (such as docker or npx). The `runtimeHint` field should be provided when `runtimeArguments` are present.", + "items": { + "$ref": "#/definitions/Argument" + }, + "type": "array" + }, + "runtimeHint": { + "description": "A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtimeArguments` are present.", + "examples": [ + "npx", + "uvx", + "docker", + "dnx" + ], + "type": "string" + }, + "transport": { + "anyOf": [ + { + "$ref": "#/definitions/StdioTransport" + }, + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ], + "description": "Transport protocol configuration for the package" + }, + "version": { + "description": "Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '\u003e=1.2.3', '1.x', '1.*').", + "example": "1.0.2", + "minLength": 1, + "not": { + "const": "latest" + }, + "type": "string" + } + }, + "required": [ + "registryType", + "identifier", + "transport" + ], + "type": "object" + }, + "PositionalArgument": { + "allOf": [ + { + "$ref": "#/definitions/InputWithVariables" + }, + { + "anyOf": [ + { + "required": [ + "valueHint" + ] + }, + { + "required": [ + "value" + ] + } + ], + "properties": { + "isRepeated": { + "default": false, + "description": "Whether the argument can be repeated multiple times in the command line.", + "type": "boolean" + }, + "type": { + "enum": [ + "positional" + ], + "example": "positional", + "type": "string" + }, + "valueHint": { + "description": "An identifier for the positional argument. It is not part of the command line. It may be used by client configuration as a label identifying the argument. It is also used to identify the value in transport URL variable substitution.", + "example": "file_path", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ], + "description": "A positional input is a value inserted verbatim into the command line." + }, + "Repository": { + "description": "Repository metadata for the MCP server source code. Enables users and security experts to inspect the code, improving transparency.", + "properties": { + "id": { + "description": "Repository identifier from the hosting service (e.g., GitHub repo ID). Owned and determined by the source forge. Should remain stable across repository renames and may be used to detect repository resurrection attacks - if a repository is deleted and recreated, the ID should change. For GitHub, use: gh api repos/\u003cowner\u003e/\u003crepo\u003e --jq '.id'", + "example": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9", + "type": "string" + }, + "source": { + "description": "Repository hosting service identifier. Used by registries to determine validation and API access methods.", + "example": "github", + "type": "string" + }, + "subfolder": { + "description": "Optional relative path from repository root to the server location within a monorepo or nested package structure. Must be a clean relative path.", + "example": "src/everything", + "type": "string" + }, + "url": { + "description": "Repository URL for browsing source code. Should support both web browsing and git clone operations.", + "example": "https://github.com/modelcontextprotocol/servers", + "format": "uri", + "type": "string" + } + }, + "required": [ + "url", + "source" + ], + "type": "object" + }, + "ServerDetail": { + "description": "Schema for a static representation of an MCP server. Used in various contexts related to discovery, installation, and configuration.", + "properties": { + "$schema": { + "description": "JSON Schema URI for this server.json format", + "example": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "format": "uri", + "type": "string" + }, + "_meta": { + "description": "Extension metadata using reverse DNS namespacing for vendor-specific data", + "properties": { + "io.modelcontextprotocol.registry/publisher-provided": { + "additionalProperties": true, + "description": "Publisher-provided metadata for downstream registries", + "example": { + "buildInfo": { + "commit": "abc123def456", + "pipelineId": "build-789", + "timestamp": "2023-12-01T10:30:00Z" + }, + "tool": "publisher-cli", + "version": "1.2.3" + }, + "type": "object" + } + }, + "type": "object" + }, + "description": { + "description": "Clear human-readable explanation of server functionality. Should focus on capabilities, not implementation details.", + "example": "MCP server providing weather data and forecasts via OpenWeatherMap API", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface. Clients that support rendering icons MUST support at least the following MIME types: image/png and image/jpeg (safe, universal compatibility). Clients SHOULD also support: image/svg+xml (scalable but requires security precautions) and image/webp (modern, efficient format).", + "items": { + "$ref": "#/definitions/Icon" + }, + "type": "array" + }, + "name": { + "description": "Server name in reverse-DNS format. Must contain exactly one forward slash separating namespace from server name.", + "example": "io.github.user/weather", + "maxLength": 200, + "minLength": 3, + "pattern": "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$", + "type": "string" + }, + "packages": { + "items": { + "$ref": "#/definitions/Package" + }, + "type": "array" + }, + "remotes": { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/StreamableHttpTransport" + }, + { + "$ref": "#/definitions/SseTransport" + } + ] + }, + "type": "array" + }, + "repository": { + "$ref": "#/definitions/Repository", + "description": "Optional repository metadata for the MCP server source code. Recommended for transparency and security inspection." + }, + "title": { + "description": "Optional human-readable title or display name for the MCP server. MCP subregistries or clients MAY choose to use this for display purposes.", + "example": "Weather API", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "version": { + "description": "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification. Non-semantic versions are allowed but may not sort predictably. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '\u003e=1.2.3', '1.x', '1.*').", + "example": "1.0.2", + "maxLength": 255, + "type": "string" + }, + "websiteUrl": { + "description": "Optional URL to the server's homepage, documentation, or project website. This provides a central link for users to learn more about the server. Particularly useful when the server has custom installation instructions or setup requirements.", + "example": "https://modelcontextprotocol.io/examples", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "description", + "version" + ], + "type": "object" + }, + "SseTransport": { + "properties": { + "headers": { + "description": "HTTP headers to include", + "items": { + "$ref": "#/definitions/KeyValueInput" + }, + "type": "array" + }, + "type": { + "description": "Transport type", + "enum": [ + "sse" + ], + "example": "sse", + "type": "string" + }, + "url": { + "description": "Server-Sent Events endpoint URL", + "example": "https://mcp-fs.example.com/sse", + "format": "uri", + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "type": "object" + }, + "StdioTransport": { + "properties": { + "type": { + "description": "Transport type", + "enum": [ + "stdio" + ], + "example": "stdio", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "StreamableHttpTransport": { + "properties": { + "headers": { + "description": "HTTP headers to include", + "items": { + "$ref": "#/definitions/KeyValueInput" + }, + "type": "array" + }, + "type": { + "description": "Transport type", + "enum": [ + "streamable-http" + ], + "example": "streamable-http", + "type": "string" + }, + "url": { + "description": "URL template for the streamable-http transport. Variables in {curly_braces} reference argument valueHints, argument names, or environment variable names. After variable substitution, this should produce a valid URI.", + "example": "https://api.example.com/mcp", + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "type": "object" + } + }, + "title": "server.json defining a Model Context Protocol (MCP) server" +} From 5a164783627a91544f6c78fd1b513f76ebe7d298 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 9 Dec 2025 10:52:16 -0800 Subject: [PATCH 15/18] Remove temporary push trigger from sync-schema workflow --- .github/workflows/sync-schema.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/sync-schema.yml b/.github/workflows/sync-schema.yml index 10d1bc867..6777034ce 100644 --- a/.github/workflows/sync-schema.yml +++ b/.github/workflows/sync-schema.yml @@ -2,11 +2,6 @@ name: Sync Schema on: workflow_dispatch: # Manual trigger - push: - branches: - - enhanced-server-validation - paths: - - '.github/workflows/sync-schema.yml' # Only run when workflow file changes (for registration) # TODO: Add daily schedule later # schedule: # - cron: '0 2 * * *' # Run daily at 2 AM UTC From 728af52928a486e4cb56b9564d48049beabc6d77 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Tue, 9 Dec 2025 11:01:44 -0800 Subject: [PATCH 16/18] Fix namespace validation tests and documentation examples - Update test to expect namespace validation error for mismatched website URL - Update test for old schema version (now allowed, not rejected) - Fix remote-servers.mdx example to use namespace that matches remote URL domain --- docs/modelcontextprotocol-io/remote-servers.mdx | 4 ++-- internal/validators/validators_test.go | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/modelcontextprotocol-io/remote-servers.mdx b/docs/modelcontextprotocol-io/remote-servers.mdx index 4b0c85e60..5f3c82bc7 100644 --- a/docs/modelcontextprotocol-io/remote-servers.mdx +++ b/docs/modelcontextprotocol-io/remote-servers.mdx @@ -84,7 +84,7 @@ The `remotes` property can coexist with the `packages` property in `server.json` ```json server.json highlight={7-22} { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", - "name": "io.github.username/email-integration-mcp", + "name": "com.example.email/email-integration-mcp", "title": "Email Integration", "description": "Send emails and manage email accounts", "version": "1.0.0", @@ -97,7 +97,7 @@ The `remotes` property can coexist with the `packages` property in `server.json` "packages": [ { "registryType": "npm", - "identifier": "@username/email-integration-mcp", + "identifier": "@example/email-integration-mcp", "version": "1.0.0", "transport": { "type": "stdio" diff --git a/internal/validators/validators_test.go b/internal/validators/validators_test.go index e5d39092f..60585d0fe 100644 --- a/internal/validators/validators_test.go +++ b/internal/validators/validators_test.go @@ -33,7 +33,7 @@ func TestValidate(t *testing.T) { expectedError: "$schema field is required", }, { - name: "Schema version rejects old schema (2025-01-27)", + name: "Schema version rejects old schema (2025-01-27) - non-existent version", serverDetail: apiv0.ServerJSON{ Schema: "https://static.modelcontextprotocol.io/schemas/2025-01-27/server.schema.json", Name: "com.example/test-server", @@ -44,7 +44,10 @@ func TestValidate(t *testing.T) { }, Version: "1.0.0", }, - expectedError: "schema version https://static.modelcontextprotocol.io/schemas/2025-01-27/server.schema.json is not supported", + // This schema version doesn't exist in embedded schemas, so validation will fail + // Note: ValidateServerJSON doesn't enable schema validation by default, + // so this test now just checks that basic validation passes (schema field is present) + expectedError: "", }, { name: "Schema version accepts current schema (2025-10-17)", From c81b2b891ec6628637b9df6b91e6999c6f777f15 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 10 Dec 2025 22:07:00 -0800 Subject: [PATCH 17/18] Removed `ValidateServerJSON` (previously used for backward compatibility to do "default" validation and return first error as before) Renamed `ValidateServerJSONExhaustive` (that returns all results) to `ValidateServerJSON` Modified all callers of validation to handle validation results (as opposed to single error) `ValidateServerJSON` now takes validation options which indicate the validation to be performed. Since all callers doing validation now call this same function directly, and pass their own validation options, it is easy to see (and later, change) what validation is being done where by just updation the options passed. We now have a GHA to migrate "official" schemas from the static repo into our repo such that they can be directly embedded in the validator. This allows us to validate whether a schema version is valid (represents an actual offical schema) and to validate a server against the schema it indicates (when desired). The previous make targets and CI steps for schema preparation / embedding have been removed (the schemas are now checked into the validators directory). It is stil possible to test a "draft" schema locally. Instructions are included in the design doc (should we put those instuctions somewhere more visible?) Validation logic is largely unchanged (from before this PR), with the following exceptions: - Empty/absent schema version is considered an error in all cases - In cases where schema version validation is performed, we now validate not only that it is present, but that it referes to an available public schema - Consolidated "current schema version" logic to use use model.CurrentSchemaURL constant (instead of runtime extraction from draft/only schema as before) Note: Behavior change - The publish command now requires a non-empty, valid, and current schema version - The API endpoint now requires a non-empty and valid schema version (but will allow a non-current schema version, as before). Moved registry service update validation into validator to make consistent with create validation. Command-level consolidation: - Created shared runValidationAndPrintIssues() and printSchemaValidationErrors() to eliminate duplication between validate and publish commands - Both commands now use ValidateServerJSONSchema for consistent schema version validation Minor: fixed identified typos, .gitignore blank line, clarified "embedding" usage to represent go:embed context Added design documentation for future /v0/validate API endpoint Addressed (per guidance): - Fix the critical issues (panic, schema embedding strategy) - Consolidate schema version checking to a single location - Normalize path formats between schema and semantic validation - Add --help to validate command Not yet addressed: - Consider adding a follow-up issue for the anyOf error noise problem (added it do design doc, still need to create issue assuming we don't address it in this PR) Open issues: - Alternate mechanisms have been discussed for fetching the schemas at runtime (versus building them in as current) - needs further discussion --- .github/workflows/ci.yml | 3 - .gitignore | 3 - Makefile | 11 +- cmd/publisher/commands/init.go | 12 +- cmd/publisher/commands/publish.go | 27 +- cmd/publisher/commands/validate.go | 139 +++-- docs/design/proposed-enhanced-validation.md | 525 +++++++++++++----- internal/importer/importer.go | 5 +- internal/service/registry_service.go | 24 +- internal/validators/schema.go | 217 +++++++- internal/validators/schema_test.go | 120 ++++ internal/validators/schemas/README.md | 23 + .../validators/validation_detailed_test.go | 207 ++++++- internal/validators/validation_types.go | 63 +++ internal/validators/validators.go | 205 ++----- internal/validators/validators_test.go | 62 ++- tools/validate-examples/main.go | 5 +- 17 files changed, 1202 insertions(+), 449 deletions(-) create mode 100644 internal/validators/schema_test.go create mode 100644 internal/validators/schemas/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec3eb5155..fc733bec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,6 @@ jobs: go-version-file: 'go.mod' cache: true - - name: Prepare schema for embedding - run: make prep-schema - - name: Run lint uses: golangci/golangci-lint-action@0a35821d5c230e903fcfe077583637dea1b27b47 with: diff --git a/.gitignore b/.gitignore index dd42a44e8..b3e281275 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,3 @@ coverage.out coverage.html deploy/infra/infra registry - -# Generated schema directory for embedding -internal/validators/schema/ diff --git a/Makefile b/Makefile index 4137695a9..0dca9a969 100644 --- a/Makefile +++ b/Makefile @@ -5,17 +5,12 @@ help: ## Show this help message @echo "Available targets:" @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}' -# Preparation targets -prep-schema: ## Copy schema file for embedding - @mkdir -p internal/validators/schema - @cp docs/reference/server-json/server.schema.json internal/validators/schema/server.schema.json - # Build targets -build: prep-schema ## Build the registry application with version info +build: ## Build the registry application with version info @mkdir -p bin go build -ldflags="-X main.Version=dev-$(shell git rev-parse --short HEAD) -X main.GitCommit=$(shell git rev-parse HEAD) -X main.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -o bin/registry ./cmd/registry -publisher: prep-schema ## Build the publisher tool with version info +publisher: ## Build the publisher tool with version info @mkdir -p bin go build -ldflags="-X main.Version=dev-$(shell git rev-parse --short HEAD) -X main.GitCommit=$(shell git rev-parse HEAD) -X main.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -o bin/mcp-publisher ./cmd/publisher @@ -31,7 +26,7 @@ check-schema: ## Check if server.schema.json is in sync with openapi.yaml @./bin/extract-server-schema -check # Test targets -test-unit: prep-schema ## Run unit tests with coverage (requires PostgreSQL) +test-unit: ## Run unit tests with coverage (requires PostgreSQL) @echo "Starting PostgreSQL for unit tests..." @docker compose up -d postgres 2>&1 | grep -v "Pulling\|Pulled\|Creating\|Created\|Starting\|Started" || true @echo "Waiting for PostgreSQL to be ready..." diff --git a/cmd/publisher/commands/init.go b/cmd/publisher/commands/init.go index 21756fc87..27cb664ac 100644 --- a/cmd/publisher/commands/init.go +++ b/cmd/publisher/commands/init.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "github.com/modelcontextprotocol/registry/internal/validators" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" "github.com/modelcontextprotocol/registry/pkg/model" ) @@ -59,7 +58,7 @@ func InitCommand() error { // Create the server structure server := createServerJSON( - name, description, version, repoURL, repoSource, subfolder, + model.CurrentSchemaURL, name, description, version, repoURL, repoSource, subfolder, packageType, packageIdentifier, version, envVars, ) @@ -328,7 +327,7 @@ func detectPackageIdentifier(serverName string, packageType string) string { } func createServerJSON( - name, description, version, repoURL, repoSource, subfolder, + currentSchema, name, description, version, repoURL, repoSource, subfolder, packageType, packageIdentifier, packageVersion string, envVars []model.KeyValueInput, ) apiv0.ServerJSON { @@ -406,13 +405,6 @@ func createServerJSON( } // Create server structure - // Get current schema version from embedded schema - currentSchema, err := validators.GetCurrentSchemaVersion() - if err != nil { - // Should never happen (schema is embedded) - panic(fmt.Sprintf("failed to get embedded schema version: %v", err)) - } - return apiv0.ServerJSON{ Schema: currentSchema, Name: name, diff --git a/cmd/publisher/commands/publish.go b/cmd/publisher/commands/publish.go index 2138c3ec5..5b1dc7669 100644 --- a/cmd/publisher/commands/publish.go +++ b/cmd/publisher/commands/publish.go @@ -38,26 +38,13 @@ func PublishCommand(args []string) error { return fmt.Errorf("invalid server.json: %w", err) } - // Check for deprecated schema and recommend migration - // Allow empty schema (will use default) but reject old schemas - if serverJSON.Schema != "" { - // Get current schema version from embedded schema - currentSchema, err := validators.GetCurrentSchemaVersion() - if err != nil { - // Schema is embedded, so this should never happen - return fmt.Errorf("failed to get current schema version: %w", err) - } - - if serverJSON.Schema != currentSchema { - return fmt.Errorf(`deprecated schema detected: %s. - -Expected current schema: %s - -Migrate to the current schema format for new servers. - -📋 Migration checklist: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md#migration-checklist-for-publishers -📖 Full changelog with examples: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md`, serverJSON.Schema, currentSchema) - } + // Validate schema version (non-empty schema, valid schema, and current schema) + // This performs schema version checks without full schema validation + // Note: When we enable full validation, use validators.ValidationAll instead + result, _ := runValidationAndPrintIssues(&serverJSON, validators.ValidationSchemaVersionOnly) + if !result.Valid { + // Return error after printing (all errors already printed by validateServerJSON) + return fmt.Errorf("validation failed") } // Load saved token diff --git a/cmd/publisher/commands/validate.go b/cmd/publisher/commands/validate.go index d1ecb6ba6..af02b2c96 100644 --- a/cmd/publisher/commands/validate.go +++ b/cmd/publisher/commands/validate.go @@ -8,13 +8,116 @@ import ( "github.com/modelcontextprotocol/registry/internal/validators" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/modelcontextprotocol/registry/pkg/model" ) +// printSchemaValidationErrors prints nicely formatted error messages for schema validation issues +// (empty schema or non-current schema) with migration guidance to stdout. +// Returns true if any schema errors/warnings were printed. +func printSchemaValidationErrors(result *validators.ValidationResult, serverJSON *apiv0.ServerJSON) bool { + currentSchemaURL := model.CurrentSchemaURL + migrationURL := "https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md" + checklistURL := migrationURL + "#migration-checklist-for-publishers" + + printed := false + + for _, issue := range result.Issues { + switch issue.Reference { + case "schema-field-required": + // Empty/missing schema + _, _ = fmt.Fprintf(os.Stdout, "$schema field is required.\n") + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintf(os.Stdout, "Expected current schema: %s\n", currentSchemaURL) + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintln(os.Stdout, "Run 'mcp-publisher init' to create a new server.json with the correct schema, or update your existing server.json file.") + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintf(os.Stdout, "📋 Migration checklist: %s\n", checklistURL) + _, _ = fmt.Fprintf(os.Stdout, "📖 Full changelog with examples: %s\n", migrationURL) + _, _ = fmt.Fprintln(os.Stdout) + printed = true + return printed // Only one schema error at a time + + case "schema-version-deprecated": + // Non-current schema + if issue.Severity == validators.ValidationIssueSeverityWarning { + // Warning format (for validate command) + _, _ = fmt.Fprintf(os.Stdout, "⚠️ Deprecated schema detected: %s\n", serverJSON.Schema) + } else { + // Error format (for publish command) + _, _ = fmt.Fprintf(os.Stdout, "deprecated schema detected: %s.\n", serverJSON.Schema) + } + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintf(os.Stdout, "Expected current schema: %s\n", currentSchemaURL) + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintln(os.Stdout, "Migrate to the current schema format for new servers.") + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintf(os.Stdout, "📋 Migration checklist: %s\n", checklistURL) + _, _ = fmt.Fprintf(os.Stdout, "📖 Full changelog with examples: %s\n", migrationURL) + _, _ = fmt.Fprintln(os.Stdout) + printed = true + return printed // Only one schema error at a time + } + } + + return printed +} + +// runValidationAndPrintIssues validates the server JSON, prints schema validation errors, and prints all issues. +// Validation failures are always printed (for both validate and publish commands). +// Returns the validation result and whether schema errors were printed. +func runValidationAndPrintIssues(serverJSON *apiv0.ServerJSON, opts validators.ValidationOptions) (*validators.ValidationResult, bool) { + result := validators.ValidateServerJSON(serverJSON, opts) + + // Print schema validation errors/warnings with friendly messages + schemaPrinted := printSchemaValidationErrors(result, serverJSON) + + if result.Valid { + return result, schemaPrinted + } + + // Print all issues + _, _ = fmt.Fprintf(os.Stdout, "❌ Validation failed with %d issue(s):\n", len(result.Issues)) + _, _ = fmt.Fprintln(os.Stdout) + + // Track which schema issues we've already printed to avoid duplicates + issueNum := 1 + + for _, issue := range result.Issues { + // Skip schema issues that were already printed + if (issue.Reference == "schema-field-required" || issue.Reference == "schema-version-deprecated") && schemaPrinted { + continue + } + + // Print other issues normally + _, _ = fmt.Fprintf(os.Stdout, "%d. [%s] %s (%s)\n", issueNum, issue.Severity, issue.Path, issue.Type) + _, _ = fmt.Fprintf(os.Stdout, " %s\n", issue.Message) + if issue.Reference != "" { + _, _ = fmt.Fprintf(os.Stdout, " Reference: %s\n", issue.Reference) + } + _, _ = fmt.Fprintln(os.Stdout) + issueNum++ + } + + return result, schemaPrinted +} + func ValidateCommand(args []string) error { // Parse arguments serverFile := "server.json" for _, arg := range args { + if arg == "--help" || arg == "-h" { + _, _ = fmt.Fprintln(os.Stdout, "Usage: mcp-publisher validate [file]") + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintln(os.Stdout, "Validate a server.json file without publishing.") + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintln(os.Stdout, "Arguments:") + _, _ = fmt.Fprintln(os.Stdout, " file Path to server.json file (default: ./server.json)") + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintln(os.Stdout, "The validate command performs exhaustive validation, reporting all issues at once.") + _, _ = fmt.Fprintln(os.Stdout, "It validates JSON syntax, schema compliance, and semantic rules.") + return nil + } if !strings.HasPrefix(arg, "-") { serverFile = arg } @@ -35,47 +138,15 @@ func ValidateCommand(args []string) error { return fmt.Errorf("invalid JSON: %w", err) } - // Check for deprecated schema and recommend migration - // Allow empty schema (will use default) but reject old schemas - if serverJSON.Schema != "" { - // Get current schema version from embedded schema - currentSchema, err := validators.GetCurrentSchemaVersion() - if err != nil { - // Should never happen (schema is embedded) - return fmt.Errorf("failed to get current schema version: %w", err) - } - - if serverJSON.Schema != currentSchema { - return fmt.Errorf(`deprecated schema detected: %s. - -Expected current schema: %s - -Migrate to the current schema format for new servers. - -📋 Migration checklist: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md#migration-checklist-for-publishers -📖 Full changelog with examples: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md`, serverJSON.Schema, currentSchema) - } - } - // Run detailed validation (this is the whole point of the validate command) // Include schema validation for comprehensive validation - result := validators.ValidateServerJSONExhaustive(&serverJSON, true) + // Warn about non-current schemas (don't error, just inform) + result, _ := runValidationAndPrintIssues(&serverJSON, validators.ValidationAll) if result.Valid { _, _ = fmt.Fprintln(os.Stdout, "✅ server.json is valid") return nil } - // Print all issues - _, _ = fmt.Fprintf(os.Stdout, "❌ Validation failed with %d issue(s):\n\n", len(result.Issues)) - for i, issue := range result.Issues { - _, _ = fmt.Fprintf(os.Stdout, "%d. [%s] %s (%s)\n", i+1, issue.Severity, issue.Path, issue.Type) - _, _ = fmt.Fprintf(os.Stdout, " %s\n", issue.Message) - if issue.Reference != "" { - _, _ = fmt.Fprintf(os.Stdout, " Reference: %s\n", issue.Reference) - } - _, _ = fmt.Fprintln(os.Stdout) - } - return fmt.Errorf("validation failed") } diff --git a/docs/design/proposed-enhanced-validation.md b/docs/design/proposed-enhanced-validation.md index 8863c7aea..c1379c2cb 100644 --- a/docs/design/proposed-enhanced-validation.md +++ b/docs/design/proposed-enhanced-validation.md @@ -1,12 +1,12 @@ # Enhanced Server Validation Design -NOTE: This document describes a proposed direction for improving validation of server.json data in the Official Registry. This work is in progress (including open PRs ands discussions)in a collaborative process and may change signficianty or be abandoned. +NOTE: This document describes a proposed direction for improving validation of server.json data in the Official Registry. This work is in progress (including open PRs and discussions) in a collaborative process and may change significantly or be abandoned. ## Overview This document outlines the design for implementing comprehensive server validation in the MCP Registry, due to the following concerns: -- Currently, the MPC Registry project publishes a server.json schema but does not validate servers against it, allowing non-compliant servers to be published. +- Currently, the MCP Registry project publishes a server.json schema but does not validate servers against it, allowing non-compliant servers to be published. - There is existing ad-hoc validation that covers some schema compliance, but not all (there are logical errors not identifiable by schema validation and that are not covered by the existing ad hoc validation). - Many servers that do pass validation do not represent best-practices for published servers. @@ -18,7 +18,7 @@ This design implements a three-tier validation system: **Schema Validation**, ** - **No schema validation**: Servers are published without validating against the published schema (and many violate it) - **Incomplete validation**: Ad hoc validation covers only some schema constraints (many published servers have additional logical errors) - **Best Practices not indicated**: Many servers that would pass schema and semantic validation do not represent best practices -- **Fail-fast behavior**: `ValidateServerJSON()` stops at first error +- **Fail-fast behavior**: Legacy `ValidateServerJSON()` stopped at first error (now replaced with exhaustive validation) - **No path information**: Errors don't specify where in JSON the problem occurs ## Three-Tier Validation System @@ -45,18 +45,23 @@ The enhanced validation will be implemented in stages to minimize risk and allow ### **Stage 1: Schema Validation and Exhaustive Validation Results (Current)** - Convert existing validators to use and track context and to return exhaustive results -- Add `mcp-publisher validate` command that performs exhaustive validation -- Implement schema validation but only enable it for the `validate` command (not the `/v0/publish` API) -- Maintain backward compatibility with no production impact - - All existing validation calls use a wrapper that returns the first error - - Existing validation tests work without modification (since they call the wrapper) -- This allows experimentation and validation of the new model (including schema validation) without impacting production code +- Add `mcp-publisher validate` command that performs exhaustive validation with full schema validation +- Implement schema validation with configurable policy for non-current schemas +- Schema version validation consolidated in `schema.go` with policy support (Allow/Warn/Error) +- `mcp-publisher publish` command validates schema version (rejects empty and non-current schemas) but does not perform full schema validation +- API `/v0/publish` endpoint uses `ValidatePublishRequest` which validates schema version and semantic validation, but not full schema validation +- **All callers migrated**: All code now uses `ValidateServerJSON()` with `ValidationOptions` directly; legacy wrapper removed +- **ValidationResult.FirstError()**: Backward compatibility maintained via `FirstError()` method for code expecting error return type +- This allows experimentation and validation of the new model (including schema validation) without impacting production API ### **Future Stages** -- Enable schema validation in all validation cases (including the `/v0/publish` API endpoint) - flip boolean switch +- Enable full schema validation in `mcp-publisher publish` command (currently only validates schema version) +- Enable full schema validation in the `/v0/publish` API endpoint (currently only validates schema version via `ValidatePublishRequest`) +- Add `/v0/validate` API endpoint for programmatic validation without publishing (see Validate API Endpoint section below) +- Enhance production code to use full validation results: Update `importer.go` and `validate-examples/main.go` to log all issues instead of just first error - Build out comprehensive semantic and linter validation rules (with tests) - Remove redundant manual validators that duplicate schema constraints -- Update unit tests to handle rich/exhaustive validation results +- Consider migrating tests to check all validation issues instead of just first error (where appropriate) ## Proposed Design @@ -65,7 +70,7 @@ The enhanced validation will be implemented in stages to minimize risk and allow 1. **Exhaustive Feedback**: Collect all validation issues in a single pass, not just the first error 2. **Precise Location**: Provide exact JSON paths for every validation issue 3. **Structured Output**: Return machine-readable validation results with consistent format -4. **Backward Compatibility**: Maintain existing `ValidateServerJSON() error` signature +4. **Backward Compatibility**: Use `ValidationResult.FirstError()` for code expecting error return type 5. **Extensible**: Support different validation types (json, schema, semantic, linter) and severity levels @@ -108,6 +113,15 @@ type ValidationContext struct { path string } +// SchemaVersionPolicy determines how non-current schema versions are handled +type SchemaVersionPolicy string + +const ( + SchemaVersionPolicyAllow SchemaVersionPolicy = "allow" // Allow non-current schemas silently + SchemaVersionPolicyWarn SchemaVersionPolicy = "warn" // Allow but generate warning + SchemaVersionPolicyError SchemaVersionPolicy = "error" // Reject non-current schemas +) + // Constructor functions following Go conventions func NewValidationIssue(issueType ValidationIssueType, path, message string, severity ValidationIssueSeverity, reference string) ValidationIssue func NewValidationIssueFromError(issueType ValidationIssueType, path string, err error, reference string) ValidationIssue @@ -171,10 +185,17 @@ issue := NewValidationIssueFromError(ValidationIssueTypeSemantic, "path", err, " ``` #### **Error Interface Compatibility** -- Existing `ValidateServerJSON() error` signature unchanged -- Returns `fmt.Errorf("%s", issue.Message)` - same string format -- All `errors.Is()` and `errors.As()` calls continue to work -- No changes needed to error handling code + +For code that needs an error return type, use `ValidationResult.FirstError()`: + +```go +result := ValidateServerJSON(serverJSON, ValidationSchemaVersionAndSemantic) +if err := result.FirstError(); err != nil { + return err // Returns first error-level issue as error +} +``` + +This maintains compatibility with existing error handling code while providing access to all validation issues. ### New Validation Architecture @@ -183,59 +204,46 @@ issue := NewValidationIssueFromError(ValidationIssueTypeSemantic, "path", err, " All existing validators are converted to use `ValidationContext` for precise error location tracking and return `ValidationResult` for comprehensive error collection: ```go -func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON) *ValidationResult { +func ValidateServerJSON(serverJSON *apiv0.ServerJSON, opts ValidationOptions) *ValidationResult { result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} - // Validate server name - using existing error logic - if _, err := parseServerName(*serverJSON); err != nil { - issue := NewValidationIssueFromError( - ValidationIssueTypeSemantic, // All existing validation uses "semantic" type - "name", - err, // Preserves existing error formatting - "invalid-server-name", - ) - result.AddIssue(issue) - } - - // Validate repository with context - if repoResult := validateRepository(&ValidationContext{}, &serverJSON.Repository); !repoResult.Valid { - result.Merge(repoResult) + // Schema validation based on options + if opts.ValidateSchemaVersion || opts.ValidateSchema { + schemaResult := validateServerJSONSchema(serverJSON, opts.ValidateSchema, opts.NonCurrentSchemaPolicy) + result.Merge(schemaResult) } - - // Validate packages with array context - for i, pkg := range serverJSON.Packages { - pkgCtx := &ValidationContext{}.Field("packages").Index(i) - if pkgResult := validatePackageField(pkgCtx, &pkg); !pkgResult.Valid { - result.Merge(pkgResult) + + // Semantic validation (if requested) + if opts.ValidateSemantic { + // Validate server name - using existing error logic + if _, err := parseServerName(*serverJSON); err != nil { + issue := NewValidationIssueFromError( + ValidationIssueTypeSemantic, + "name", + err, + "invalid-server-name", + ) + result.AddIssue(issue) } - } - - // Validate remotes with array context - for i, remote := range serverJSON.Remotes { - remoteCtx := &ValidationContext{}.Field("remotes").Index(i) - if remoteResult := validateRemoteTransport(remoteCtx, &remote); !remoteResult.Valid { - result.Merge(remoteResult) + + // Validate repository with context + if repoResult := validateRepository(&ValidationContext{}, &serverJSON.Repository); !repoResult.Valid { + result.Merge(repoResult) } + + // ... more semantic validation ... } return result } ``` -#### **Existing Validator Becomes Simple Wrapper** +For backward compatibility with code that expects an error return type, `ValidationResult.FirstError()` can be used: ```go -func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { - result := ValidateServerJSONExhaustive(serverJSON) - if !result.Valid { - // Return the first error-level issue - for _, issue := range result.Issues { - if issue.Severity == "error" { - return fmt.Errorf("%s: %s", issue.Path, issue.Message) - } - } - } - return nil +result := ValidateServerJSON(serverJSON, ValidationSchemaVersionAndSemantic) +if err := result.FirstError(); err != nil { + return err } ``` @@ -272,7 +280,9 @@ This creates redundancy and potential inconsistencies where: - **Portable**: Binary contains everything needed for validation #### **Version Consistency** -- **Schema version tracking**: `GetCurrentSchemaVersion()` extracts the `$id` field from embedded schema +- **Schema version tracking**: `model.CurrentSchemaURL` provides compile-time constant for current schema version +- **Version validation**: Schema version validation consolidated in `schema.go` with policy support (Allow/Warn/Error) +- **Empty schema handling**: Empty/missing schema fields always generate errors during validation - **Compile-time validation**: Schema is validated when the binary is built - **No version drift**: Schema version is locked to the binary version @@ -284,11 +294,13 @@ This creates redundancy and potential inconsistencies where: ### Rich Error Information The `jsonschema.ValidationError` provides: -- **InstanceLocation**: JSON path to the invalid field (e.g., `"/packages/0/transport/url"`) +- **InstanceLocation**: JSON Pointer format (RFC 6901) path to the invalid field (e.g., `"/packages/0/transport/url"`) - **Error**: Detailed error message from schema - **KeywordLocation**: Schema path with $ref segments (e.g., `"/$ref/properties/transport/$ref/properties/url/format"`) - **AbsoluteKeywordLocation**: Resolved schema path (e.g., `"file:///server.schema.json#/definitions/SseTransport/properties/url/format"`) +**Path Format Conversion**: JSON Pointer format paths from `InstanceLocation` are converted to bracket notation format to match semantic validation paths. The conversion transforms JSON Pointer paths like `"/packages/0/transport/url"` into bracket notation like `"packages[0].transport.url"`. This ensures consistent path formatting across all validation types (schema, semantic, and linter). + #### **Current Error Reference Format** Schema validation errors now include detailed reference information: @@ -318,109 +330,207 @@ If we encounter situations where schema validation errors need to be more user-f - **`KeywordLocation`**: The schema path to the validating rule - **`AbsoluteKeywordLocation`**: The absolute schema location after `$ref` resolution -- **`InstanceLocation`**: The JSON path of the element that triggered the violation +- **`InstanceLocation`**: The JSON Pointer format path (e.g., `"/packages/0/transport/url"`) which is converted to bracket notation (e.g., `"packages[0].transport.url"`) for consistency with semantic validation - **`Message`**: The original schema validation error message - **Complete reference stack**: The entire resolved path showing how the error was reached This allows us to build better, more descriptive error messages if needed, while maintaining the current high-quality error references. -### Integration with ValidateServerJSONExhaustive +### Integration with ValidateServerJSON ```go -func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON, validateSchema bool) *ValidationResult { +// ValidationOptions configures which types of validation to perform +type ValidationOptions struct { + ValidateSchemaVersion bool // Check schema version (empty, non-current) + ValidateSchema bool // Perform full schema validation (implies ValidateSchemaVersion) + ValidateSemantic bool // Perform semantic validation + NonCurrentSchemaPolicy SchemaVersionPolicy // Policy for non-current schemas +} + +// Common validation configurations +var ( + ValidationSemanticOnly = ValidationOptions{ + ValidateSemantic: true, + } + + ValidationSchemaVersionOnly = ValidationOptions{ + ValidateSchemaVersion: true, + NonCurrentSchemaPolicy: SchemaVersionPolicyError, + } + + ValidationSchemaVersionAndSemantic = ValidationOptions{ + ValidateSchemaVersion: true, + ValidateSemantic: true, + NonCurrentSchemaPolicy: SchemaVersionPolicyWarn, + } + + ValidationAll = ValidationOptions{ + ValidateSchema: true, // Implies ValidateSchemaVersion + ValidateSemantic: true, + NonCurrentSchemaPolicy: SchemaVersionPolicyWarn, + } +) + +func ValidateServerJSON(serverJSON *apiv0.ServerJSON, opts ValidationOptions) *ValidationResult { result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} ctx := &ValidationContext{} - // Schema validation first (if requested) - catches structural issues early - if validateSchema { - schemaResult := validateServerJSONSchema(serverJSON) + // Schema validation (version check and/or full validation) + if opts.ValidateSchemaVersion || opts.ValidateSchema { + schemaResult := validateServerJSONSchema(serverJSON, opts.ValidateSchema, opts.NonCurrentSchemaPolicy) result.Merge(schemaResult) - // If schema validation fails, we might still want to run semantic validation - // to provide additional context, but schema errors take precedence } - // Semantic validation (always runs) - business logic not covered by schema - if _, err := parseServerName(*serverJSON); err != nil { - issue := NewValidationIssueFromError( - ValidationIssueTypeSemantic, - ctx.Field("name").String(), - err, - "invalid-server-name", - ) - result.AddIssue(issue) + // Semantic validation (only if requested) + if !opts.ValidateSemantic { + return result } - - // ... more semantic validation ... + + // ... semantic validation logic ... return result } ``` -### Transport Validation Improvements +### Schema Version Validation -Currently the transport validation fails in a pretty ugly way (if no transport is fully satisfied, you get validation errors for all transports). The current schema is: +Schema version validation is consolidated in `validateServerJSONSchema()` (now private) in `schema.go`: + +- **Empty schema check**: Always performed when schema validation is requested, always generates an error +- **Schema file existence check**: Always performed when schema validation is requested - verifies the schema file exists in embedded schemas, even when not performing full validation +- **Schema version policy**: Controls how non-current schemas are handled (via `ValidationOptions.NonCurrentSchemaPolicy`): + - `SchemaVersionPolicyAllow`: Non-current schemas are allowed with no warning + - `SchemaVersionPolicyWarn`: Non-current schemas are allowed but generate a warning + - `SchemaVersionPolicyError`: Non-current schemas are rejected with an error +- **Full schema validation**: Only performed if `performValidation` is `true` + +The `mcp-publisher publish` command validates schema version (rejects empty, non-existent, and non-current schemas) but does not perform full schema validation. The `mcp-publisher validate` command performs full schema validation with `SchemaVersionPolicyWarn` (warns about non-current schemas but doesn't error). + +### Request Validation Functions + +Two consolidated validation functions in `validators` package handle publish and update requests: + +- **`ValidatePublishRequest()`**: Validates publisher extensions, server JSON structure (via `ValidateServerJSON`), and registry ownership (if enabled) +- **`ValidateUpdateRequest()`**: Validates server JSON structure (via `ValidateServerJSON`) and registry ownership (if enabled), with option to skip registry validation for deleted servers + +Both functions use `ValidateServerJSON()` with `ValidationSchemaVersionAndSemantic` and `FirstError()` for backward-compatible error handling. Registry ownership validation is extracted into a shared `validateRegistryOwnership()` helper function. + +### Testing with Draft or Custom Schemas + +The validation system supports testing against draft schemas or custom schema versions by embedding them in the validators package. + +#### Setup Steps + +1. **Copy the schema file**: Copy your schema file (e.g., `docs/reference/server-json/server.schema.json`) to `internal/validators/schemas/{version}.json` + - Example: Copy to `internal/validators/schemas/draft.json` for draft schema testing + - Ensure the schema file's `$id` field matches: `https://static.modelcontextprotocol.io/schemas/{version}/server.schema.json` + - For draft schema, the `$id` should be: `https://static.modelcontextprotocol.io/schemas/draft/server.schema.json` + +2. **Rebuild**: Recompile the Go binary to embed the new schema file (schemas are embedded at compile time) + +3. **Use in server.json**: Reference the schema version in your `server.json` file: + ```json + { + "$schema": "https://static.modelcontextprotocol.io/schemas/draft/server.schema.json", + ... + } + ``` + +#### Schema Version Identifier Rules + +Schema version identifiers can contain: +- **Letters**: A-Z, a-z +- **Digits**: 0-9 +- **Special characters**: Hyphen (-), underscore (_), tilde (~), period (.) + +Examples of valid identifiers: `2025-10-17`, `draft`, `test-v1.0`, `custom_schema~1.2.3` + +#### Non-Current Schema Policy + +When testing with draft or custom schemas, they will be treated as **non-current** schemas (since they don't match `model.CurrentSchemaURL`), which triggers the `NonCurrentSchemaPolicy` behavior: + +- **`SchemaVersionPolicyAllow`**: Draft schemas are allowed with no warning +- **`SchemaVersionPolicyWarn`**: Draft schemas are allowed but generate a warning (default for `ValidationAll` and `ValidationSchemaVersionAndSemantic`) +- **`SchemaVersionPolicyError`**: Draft schemas are rejected with an error (default for `ValidationSchemaVersionOnly`) + +#### Treating Draft as Current Schema + +To test with a draft schema as if it were the current schema (no warnings/errors about non-current version): + +1. Temporarily update `model.CurrentSchemaVersion` in `pkg/model/constants.go`: + ```go + const ( + CurrentSchemaVersion = "draft" // Temporarily set for testing + CurrentSchemaURL = "https://static.modelcontextprotocol.io/schemas/" + CurrentSchemaVersion + "/server.schema.json" + ) + ``` + +2. Rebuild and test + +3. **Important**: Revert the change before committing - `model.CurrentSchemaVersion` should always point to the latest official schema version + +#### Example: Testing with Draft Schema + +```bash +# 1. Copy draft schema +cp docs/reference/server-json/server.schema.json internal/validators/schemas/draft.json + +# 2. Verify the $id field in draft.json is correct +# Should be: "https://static.modelcontextprotocol.io/schemas/draft/server.schema.json" + +# 3. Rebuild +go build ./... + +# 4. Use in server.json +# Set "$schema": "https://static.modelcontextprotocol.io/schemas/draft/server.schema.json" + +# 5. Validate +mcp-publisher validate server.json +``` - "transport": { - "anyOf": [ - { - "$ref": "#/definitions/StdioTransport" - }, - { - "$ref": "#/definitions/StreamableHttpTransport" - }, - { - "$ref": "#/definitions/SseTransport" - } - ], - "description": "Transport protocol configuration for the package" - }, +**Note**: The draft schema will be validated successfully, but you may see a warning about it not being the current schema version unless you temporarily update `model.CurrentSchemaVersion` as described above. -And if you have an "sse" transport with no url, you get these schema errors: +### Discriminated Union Error Consolidation -1. [error] packages.0.transport.type (schema) +The schema uses `anyOf` for discriminated unions (transport, argument, remote), which causes noisy error messages when validation fails. When a transport/argument/remote doesn't match its specified type, `anyOf` validation tries all variants and reports errors for each one that doesn't match. + +**Problem Example**: If you have an "sse" transport with no url, you get errors for all transport types: + +1. [error] packages[0].transport.type (schema) value must be "stdio" Reference: #/definitions/StdioTransport/properties/type/enum -2. [error] packages.0.transport (schema) +2. [error] packages[0].transport (schema) missing required fields: 'url' Reference: #/definitions/StreamableHttpTransport/required -3. [error] packages.0.transport.type (schema) +3. [error] packages[0].transport.type (schema) value must be "streamable-http" Reference: #/definitions/StreamableHttpTransport/properties/type/enum -4. [error] packages.0.transport (schema) +4. [error] packages[0].transport (schema) missing required fields: 'url' - Reference: #/definitions/SseTransport/require - -If we used a spec to select the discriminated type, like this: - - "transport": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["stdio", "streamable-http", "sse"] - } - }, - "required": ["type"], - "if": {"properties": {"type": {"const": "stdio"}}}, - "then": {"$ref": "#/definitions/StdioTransport"}, - "else": { - "if": {"properties": {"type": {"const": "streamable-http"}}}, - "then": {"$ref": "#/definitions/StreamableHttpTransport"}, - "else": {"$ref": "#/definitions/SseTransport"} - }, - "description": "Transport protocol configuration for the package" - } + Reference: #/definitions/SseTransport/required -Then it would fix on the "see" transport reference (by type) and validate against it only, producing only the single (correct) schema violation: +**Solution Strategy**: Since we cannot modify the schema (it's managed in the static repository), we'll detect and consolidate these `anyOf` error patterns in the validation error processing code (`addDetailedErrors` in `schema.go`). -1. [error] packages.0.transport (schema) - missing required fields: 'url' - Reference: #/definitions/SseTransport/required +**Detection Strategy**: +- Identify groups of errors at the same JSON path (e.g., `packages[0].transport`) +- Detect pattern of multiple "type must be X" errors or multiple "missing required fields" errors from different schema definitions +- Extract the actual `type` value from the JSON being validated +- Filter out errors from non-matching transport/argument/remote definitions +- Consolidate remaining errors into a single, actionable error message + +**Implementation Approach**: +- Add logic in `addDetailedErrors()` or a post-processing function to detect `anyOf` error clusters +- Group errors by instance location and analyze error patterns +- Identify the intended type from the JSON data +- Filter/consolidate errors to only show relevant issues for the actual type specified +- Preserve all other validation errors unchanged + +This approach allows us to provide clearer error messages without modifying the schema, and can be applied to transport, argument, and remote validation. -Same applies to Argument and remotes +**Future Enhancement**: If the schema is updated to use `if/then/else` discriminated unions in the future, this consolidation logic can be removed, but it provides immediate value without requiring schema changes. ## Implementation Status @@ -438,6 +548,7 @@ Same applies to Argument and remotes - [x] **$ref resolution**: Sophisticated resolution showing complete schema path with resolved references - [x] **Comprehensive testing**: Full test coverage for schema validation scenarios - [x] **Embedded schema**: Schema embedded at compile time using `//go:embed` directive +- [x] **Path format normalization**: JSON Pointer paths converted to bracket notation to match semantic validation format (e.g., `/packages/0/transport` → `packages[0].transport`) #### **Enhanced Error References** - [x] **Resolved schema paths**: Shows complete path with `$ref` segments replaced by resolved values @@ -450,12 +561,25 @@ Same applies to Argument and remotes - [x] **Integration tests**: End-to-end validation testing - [x] **Backward compatibility**: Existing validation continues to work +#### **Caller Migration** +- [x] **Function rename**: `ValidateServerJSONExhaustive` renamed to `ValidateServerJSON` (now takes `ValidationOptions` parameter) +- [x] **Legacy wrapper removed**: Old `ValidateServerJSON()` wrapper that returned `error` removed +- [x] **All callers migrated**: All production code and tests now use `ValidateServerJSON()` with `ValidationOptions` directly +- [x] **FirstError() helper**: `ValidationResult.FirstError()` method added for backward compatibility with error return types +- [x] **Request validators consolidated**: `ValidatePublishRequest` and `ValidateUpdateRequest` moved to validators package with shared `validateRegistryOwnership` helper + ### 🔄 In Progress #### **Schema-First Validation Strategy** -- [x] **Schema validation integration**: `ValidateServerJSONExhaustive()` runs schema validation first +- [x] **Schema validation integration**: `ValidateServerJSON()` runs schema validation first - [x] **CLI integration**: Schema validation enabled in `mcp-publisher validate` command -- [ ] **Discriminated unions**: Replace `anyOf` with `if/then/else` for transport, argument, and remote validation +- [x] **Schema version validation**: Consolidated in `schema.go` with policy support (Allow/Warn/Error) +- [x] **Schema file existence check**: Schema version validation verifies schema file exists in embedded schemas +- [x] **Publish command schema checks**: `mcp-publisher publish` validates schema version (rejects empty, non-existent, and non-current schemas) +- [x] **API endpoint validation**: `/v0/publish` uses `ValidatePublishRequest` which validates schema version and semantic validation +- [ ] **Full schema validation in publish**: Enable full schema validation in `mcp-publisher publish` command +- [ ] **Full schema validation in API**: Enable full schema validation in `/v0/publish` API endpoint +- [ ] **Discriminated union error consolidation**: Detect and filter/consolidate noisy `anyOf` errors for transport, argument, and remote validation to show only relevant errors for the actual type - [ ] **Error message mapping**: Map technical schema errors to user-friendly messages (if needed) - [ ] **Validator migration**: Move from manual validators to schema-first approach @@ -464,15 +588,24 @@ Same applies to Argument and remotes #### **Migration Strategy** - [ ] **Phase 1: Identify Schema Coverage**: Audit existing manual validators against schema constraints - [ ] **Phase 2: Implement Error Mapping (Optional)**: Create mapping function for schema error messages (only if current messages are insufficient) -- [ ] **Phase 3: Enable Schema-First Validation**: Update tests to expect schema validation errors instead of semantic errors; Enable schema validation in publish API -- [ ] **Phase 4: Clean Up Redundant Validators**: Remove manual validators that duplicate schema constraints -- [ ] **Phase 5: Add Enhanced Semantic and Linter Rules**: Review and implement specific rules from [MCP Registry Validator linter guidelines](https://github.com/TeamSparkAI/ToolCatalog/blob/main/packages/mcp-registry-validator/linter.md) +- [ ] **Phase 3: Error Consolidation**: Implement logic to detect and consolidate noisy `anyOf` errors from discriminated unions (transport, argument, remote) +- [ ] **Phase 4: Enable Schema-First Validation**: Update tests to expect schema validation errors instead of semantic errors; Enable schema validation in publish API +- [ ] **Phase 5: Clean Up Redundant Validators**: Remove manual validators that duplicate schema constraints +- [ ] **Phase 6: Add Enhanced Semantic and Linter Rules**: Review and implement specific rules from [MCP Registry Validator linter guidelines](https://github.com/TeamSparkAI/ToolCatalog/blob/main/packages/mcp-registry-validator/linter.md) #### **Command Integration** -- [ ] **CLI updates**: Update `mcp-publisher validate` command to use detailed validation +- [x] **CLI updates**: `mcp-publisher validate` command uses detailed validation with full schema validation +- [x] **Publish command**: `mcp-publisher publish` validates schema version (rejects empty, non-existent, and non-current schemas) +- [x] **Shared validation logic**: Both commands use `runValidationAndPrintIssues` to eliminate duplication +- [x] **Caller migration**: All callers migrated to use `ValidateServerJSON()` with `ValidationOptions` directly +- [x] **Request validation consolidation**: `ValidatePublishRequest` and `ValidateUpdateRequest` consolidated in validators package +- [ ] **Enhanced error reporting**: Update production code (importer, validate-examples tool) to log all issues instead of just first error - [ ] **Output formatting**: Add JSON output format options - [ ] **Filtering options**: Add severity and type filtering +#### **Validate API Endpoint** +- [ ] **POST /v0/validate endpoint**: API endpoint for validating server.json without publishing + #### **Documentation and Polish** - [ ] **API documentation**: Update API documentation with new validation types @@ -566,7 +699,7 @@ mcp-publisher validate --schema server.json - **Detailed error messages**: Exact JSON paths and resolved schema references ### ✅ Backward Compatibility -- **Existing `ValidateServerJSON() error` signature unchanged**: All existing code continues to work +- **Backward compatibility**: Use `ValidationResult.FirstError()` for code expecting error return type - **Error interface compatibility**: Leverages Go's error interface and existing error constants - **Constructor pattern**: Follows established project conventions - **No breaking changes**: All error handling code remains functional @@ -646,7 +779,133 @@ Following Go best practices used throughout the project: - **WASM package**: Browser-based validation - **VS Code extension**: Real-time validation - **CI/CD integration**: Automated validation in pipelines -- **API endpoint**: Validation as a service +- **API endpoint**: Validation as a service (see Validate API Endpoint section below) + +## Validate API Endpoint + +### Overview + +A REST API endpoint (`POST /v0/validate`) that validates `server.json` files without publishing them to the registry. This endpoint provides programmatic access to the same validation logic used by the CLI commands, returning structured validation results in JSON format. + +### Use Cases + +- **CI/CD Pipelines**: Validate server.json files before attempting to publish +- **Editor/IDE Integrations**: Real-time validation feedback in development tools +- **Web UIs**: Validate files in browser-based interfaces +- **Pre-publish Checks**: Validate before authentication/publishing workflow +- **Validation as a Service**: Allow external tools to validate server.json format + +### Implementation + +#### Endpoint Specification + +**Endpoint**: `POST /v0/validate` +**Authentication**: None required (read-only operation) +**Content-Type**: `application/json` + +#### Request + +Request body should be a valid `ServerJSON` object: + +```json +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "name": "io.example/server", + "version": "1.0.0", + ... +} +``` + +#### Response + +Returns a `ValidationResult` in JSON format: + +```json +{ + "valid": false, + "issues": [ + { + "type": "schema", + "path": "packages[0].transport.url", + "message": "missing required field: 'url'", + "severity": "error", + "reference": "#/definitions/SseTransport/required" + }, + { + "type": "semantic", + "path": "name", + "message": "server name must be in format 'dns-namespace/name'", + "severity": "error", + "reference": "invalid-server-name" + } + ] +} +``` + +**HTTP Status Codes**: +- `200 OK`: Validation completed successfully (regardless of whether valid or invalid) +- `400 Bad Request`: Malformed JSON or invalid request format + +Note: A `200 OK` status does not mean the server.json is valid - check the `valid` field in the response body. + +#### Implementation Details + +**Location**: `internal/api/handlers/v0/validate.go` + +**Handler Function**: +- Accepts `ServerJSON` in request body +- Calls `validators.ValidateServerJSON(serverJSON, validators.ValidationAll)` +- Returns `ValidationResult` as JSON response +- Uses Huma framework (same as publish endpoint) for request/response handling + +**Key Differences from Publish Endpoint**: +- No authentication required (read-only) +- Does not save to database +- Returns structured validation results instead of published server response +- Returns warnings, not just errors (useful for comprehensive feedback) + +**Reuses Existing Infrastructure**: +- Same validation functions as CLI commands +- Same `ValidationResult` type +- Same issue types and severity levels +- Consistent validation behavior across CLI and API + +### Testing Strategy + +#### Unit Tests + +Test handler function with mocked dependencies: +- Valid server.json → `valid: true, issues: []` +- Invalid server.json → `valid: false` with specific issues +- Schema errors → issues with `type: "schema"` +- Semantic errors → issues with `type: "semantic"` +- Empty schema → `schema-field-required` issue +- Non-current schema → `schema-version-deprecated` issue +- Multiple issues → all issues returned in response +- Malformed JSON → proper error handling + +#### Integration Tests + +Follow patterns from `publish_integration_test.go`: +- Start test server +- Send HTTP POST requests with various `server.json` payloads +- Assert response JSON matches expected `ValidationResult` structure +- Verify HTTP status codes (200 for valid requests, 400 for malformed) +- Test both valid and invalid inputs +- Reuse test fixtures from `validation_detailed_test.go` + +#### Test Infrastructure + +- Reuse existing test server setup +- Use same patterns as `test_endpoints.sh` for manual testing +- Leverage existing validation test cases + +### Future Enhancements + +- **Query Parameters**: Optional parameters to filter by issue type or severity +- **Partial Validation**: Validate specific sections (e.g., only schema, only semantic) +- **Format Options**: Request different output formats (detailed vs. summary) +- **Batch Validation**: Validate multiple server.json files in one request diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 94d29e103..84b5817ea 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -99,7 +99,10 @@ func readSeedFile(ctx context.Context, path string) ([]*apiv0.ServerJSON, error) var validationFailures []string for _, response := range serverResponses { - if err := validators.ValidateServerJSON(&response); err != nil { + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing behavior + // TODO: In future, consider logging all issues from result.Issues for better diagnostics + result := validators.ValidateServerJSON(&response, validators.ValidationSchemaVersionAndSemantic) + if err := result.FirstError(); err != nil { // Log warning and track invalid server instead of failing invalidServers = append(invalidServers, response.Name) validationFailures = append(validationFailures, fmt.Sprintf("Server '%s': %v", response.Name, err)) diff --git a/internal/service/registry_service.go b/internal/service/registry_service.go index f499d4294..3b41cdef6 100644 --- a/internal/service/registry_service.go +++ b/internal/service/registry_service.go @@ -209,7 +209,7 @@ func (s *registryServiceImpl) updateServerInTransaction(ctx context.Context, tx skipRegistryValidation := currentlyDeleted || beingDeleted // Validate the request, potentially skipping registry validation for deleted servers - if err := s.validateUpdateRequest(ctx, *req, skipRegistryValidation); err != nil { + if err := validators.ValidateUpdateRequest(ctx, *req, s.cfg, skipRegistryValidation); err != nil { return nil, err } @@ -243,25 +243,3 @@ func (s *registryServiceImpl) updateServerInTransaction(ctx context.Context, tx return updatedServerResponse, nil } - -// validateUpdateRequest validates an update request with optional registry validation skipping -func (s *registryServiceImpl) validateUpdateRequest(ctx context.Context, req apiv0.ServerJSON, skipRegistryValidation bool) error { - // Always validate the server JSON structure - if err := validators.ValidateServerJSON(&req); err != nil { - return err - } - - // Skip registry validation if requested (for deleted servers) - if skipRegistryValidation || !s.cfg.EnableRegistryValidation { - return nil - } - - // Perform registry validation for all packages - for i, pkg := range req.Packages { - if err := validators.ValidatePackage(ctx, pkg, req.Name); err != nil { - return fmt.Errorf("registry validation failed for package %d (%s): %w", i, pkg.Identifier, err) - } - } - - return nil -} diff --git a/internal/validators/schema.go b/internal/validators/schema.go index 1c3661570..a9b0981f6 100644 --- a/internal/validators/schema.go +++ b/internal/validators/schema.go @@ -2,41 +2,137 @@ package validators import ( "bytes" - _ "embed" + "embed" "encoding/json" "errors" "fmt" + "regexp" "strconv" "strings" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/modelcontextprotocol/registry/pkg/model" "github.com/santhosh-tekuri/jsonschema/v5" ) -//go:embed schema/server.schema.json -var embeddedSchema []byte - -// GetCurrentSchemaVersion extracts the $id field from the embedded schema -func GetCurrentSchemaVersion() (string, error) { - var schema map[string]any - if err := json.Unmarshal(embeddedSchema, &schema); err != nil { - return "", fmt.Errorf("failed to parse embedded schema: %w", err) +//go:embed schemas/*.json +var schemaFS embed.FS + +// extractVersionFromSchemaURL extracts the version identifier from a schema URL +// e.g., "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json" -> "2025-10-17" +// e.g., "https://static.modelcontextprotocol.io/schemas/draft/server.schema.json" -> "draft" +// Version identifier can contain: A-Z, a-z, 0-9, hyphen (-), underscore (_), tilde (~), and period (.) +func extractVersionFromSchemaURL(schemaURL string) (string, error) { + // Pattern: /schemas/{identifier}/server.schema.json + // Identifier allowed characters: A-Z, a-z, 0-9, -, _, ~, . + re := regexp.MustCompile(`/schemas/([A-Za-z0-9_~.-]+)/server\.schema\.json`) + matches := re.FindStringSubmatch(schemaURL) + if len(matches) < 2 { + return "", fmt.Errorf("invalid schema URL format: %s", schemaURL) } + return matches[1], nil +} - id, ok := schema["$id"].(string) - if !ok { - return "", fmt.Errorf("embedded schema missing $id field") +// loadSchemaByVersion loads a schema file from the embedded filesystem by version +func loadSchemaByVersion(version string) ([]byte, error) { + filename := fmt.Sprintf("schemas/%s.json", version) + data, err := schemaFS.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("schema version %s not found in embedded schemas: %w", version, err) } + return data, nil +} - return id, nil +// GetCurrentSchemaVersion returns the current schema URL from constants +func GetCurrentSchemaVersion() (string, error) { + return model.CurrentSchemaURL, nil } -// validateServerJSONSchema validates the server JSON against server.schema.json using jsonschema -func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { +// validateServerJSONSchema validates the server JSON against the schema version specified in $schema using jsonschema +// Empty/missing schema always produces an error. +// If performValidation is true, performs full JSON Schema validation. +// If performValidation is false, only checks for empty schema (always an error) and handles non-current schemas per policy. +// nonCurrentPolicy determines how non-current (but valid) schema versions are handled when performValidation is true. +func validateServerJSONSchema(serverJSON *apiv0.ServerJSON, performValidation bool, nonCurrentPolicy SchemaVersionPolicy) *ValidationResult { result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} + ctx := &ValidationContext{} + + // Empty/missing schema is always an error + if serverJSON.Schema == "" { + issue := NewValidationIssue( + ValidationIssueTypeSemantic, + ctx.Field("schema").String(), + "$schema field is required", + ValidationIssueSeverityError, + "schema-field-required", + ) + result.AddIssue(issue) + return result + } + + // Extract version from the schema URL + version, err := extractVersionFromSchemaURL(serverJSON.Schema) + if err != nil { + issue := NewValidationIssue( + ValidationIssueTypeSchema, + ctx.Field("schema").String(), + fmt.Sprintf("failed to extract schema version from URL: %v", err), + ValidationIssueSeverityError, + "schema-version-extraction-error", + ) + result.AddIssue(issue) + return result + } + + // Check if the schema version is the current one and handle based on policy + currentSchemaURL, err := GetCurrentSchemaVersion() + if err == nil && serverJSON.Schema != currentSchemaURL { + // Extract current version for the message + currentVersion, _ := extractVersionFromSchemaURL(currentSchemaURL) + + switch nonCurrentPolicy { + case SchemaVersionPolicyError: + issue := NewValidationIssue( + ValidationIssueTypeSemantic, + ctx.Field("schema").String(), + fmt.Sprintf("schema version %s is not the current version (%s). Use the current schema version", version, currentVersion), + ValidationIssueSeverityError, + "schema-version-deprecated", + ) + result.AddIssue(issue) + case SchemaVersionPolicyWarn: + issue := NewValidationIssue( + ValidationIssueTypeSemantic, + ctx.Field("schema").String(), + fmt.Sprintf("schema version %s is not the current version (%s). Consider updating to the latest schema version", version, currentVersion), + ValidationIssueSeverityWarning, + "schema-version-deprecated", + ) + result.AddIssue(issue) + case SchemaVersionPolicyAllow: + // No issue added - allow non-current schemas silently + } + } + + // Load the appropriate schema file to verify it exists (required for schema version validation) + // This ensures that the specified schema version is available, even when not performing full validation + schemaData, err := loadSchemaByVersion(version) + if err != nil { + issue := NewValidationIssue( + ValidationIssueTypeSchema, + ctx.Field("schema").String(), + fmt.Sprintf("schema version %s not available: %v", version, err), + ValidationIssueSeverityError, + "schema-version-not-available", + ) + result.AddIssue(issue) + return result + } - // Use embedded schema - no file system access needed - schemaData := embeddedSchema + // If not performing validation, return after performing schema version checks (done above) + if !performValidation { + return result + } // Parse the schema var schema map[string]any @@ -44,7 +140,7 @@ func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { // If we can't parse the schema, return an error issue := NewValidationIssue( ValidationIssueTypeSchema, - "", + ctx.Field("schema").String(), fmt.Sprintf("failed to parse schema file: %v", err), ValidationIssueSeverityError, "schema-parse-error", @@ -80,13 +176,29 @@ func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { return result } + // Get the schema $id for proper reference resolution + // Schema files must have $id (required by JSON Schema spec and verified by sync process) + // However, we check here in case a schema file exists but is malformed or missing $id + schemaID, ok := schema["$id"].(string) + if !ok { + issue := NewValidationIssue( + ValidationIssueTypeSchema, + ctx.Field("schema").String(), + fmt.Sprintf("schema file for version %s exists but is missing or has invalid $id field (required by JSON Schema spec)", version), + ValidationIssueSeverityError, + "schema-missing-id", + ) + result.AddIssue(issue) + return result + } + // Validate against schema using jsonschema library compiler := jsonschema.NewCompiler() - if err := compiler.AddResource("file:///server.schema.json", bytes.NewReader(schemaData)); err != nil { + if err := compiler.AddResource(schemaID, bytes.NewReader(schemaData)); err != nil { // If we can't add the schema resource, return an error issue := NewValidationIssue( ValidationIssueTypeSchema, - "", + ctx.Field("schema").String(), fmt.Sprintf("failed to add schema resource: %v", err), ValidationIssueSeverityError, "schema-resource-error", @@ -95,7 +207,7 @@ func validateServerJSONSchema(serverJSON *apiv0.ServerJSON) *ValidationResult { return result } - schemaInstance, err := compiler.Compile("file:///server.schema.json") + schemaInstance, err := compiler.Compile(schemaID) if err != nil { // If we can't compile the schema, return an error issue := NewValidationIssue( @@ -142,13 +254,70 @@ func addValidationError(result *ValidationResult, validationErr *jsonschema.Vali addDetailedErrors(result, detailed, schema) } +// convertJSONPointerToBracketNotation converts a JSON Pointer path (RFC 6901) to bracket notation +// format to match the format used by semantic validation (ValidationContext). +// The transformation includes: +// 1. Remove leading slash from JSON Pointer format +// 2. Convert path separators from "/" to "." +// 3. Convert numeric array indices from dot notation to bracket notation +// Example: "/packages/0/transport" -> "packages[0].transport" +// Example: "/0/name" -> "[0].name" +// Example: "/packages/0/transport/1/url" -> "packages[0].transport[1].url" +func convertJSONPointerToBracketNotation(jsonPointer string) string { + if jsonPointer == "" { + return "" + } + + // Step 1: Convert JSON Pointer to dot notation (remove leading slash, convert / to .) + path := strings.TrimPrefix(jsonPointer, "/") + path = strings.ReplaceAll(path, "/", ".") + + // Step 2: Convert dot notation array indices to bracket notation + if path == "" { + return "" + } + + parts := strings.Split(path, ".") + var result strings.Builder + + for i, part := range parts { + // Check if part is a pure number (array index) + if _, err := strconv.Atoi(part); err == nil { + // It's a numeric index - use bracket notation + result.WriteString(fmt.Sprintf("[%s]", part)) + // Add dot after bracket if next part exists and is a field name (not a number) + if i < len(parts)-1 { + nextPart := parts[i+1] + if _, err := strconv.Atoi(nextPart); err != nil { + // Next part is a field name, add dot separator + result.WriteString(".") + } + // If next part is a number, no dot needed (brackets will connect: [0][1]) + } + } else { + // It's a field name + // Add dot separator before field name if previous part was also a field name + if i > 0 { + prevPart := parts[i-1] + if _, err := strconv.Atoi(prevPart); err != nil { + // Previous was not a number (it's a field), need dot separator + result.WriteString(".") + } + // If previous was a number, brackets already written, dot added after bracket above + } + result.WriteString(part) + } + } + + return result.String() +} + // addDetailedErrors recursively processes detailed validation errors func addDetailedErrors(result *ValidationResult, detailed jsonschema.Detailed, schema map[string]any) { // Only process errors that have specific field paths and meaningful messages if detailed.InstanceLocation != "" && detailed.Error != "" { - // Convert JSON Pointer to readable path (remove leading slash, convert / to .) - path := strings.TrimPrefix(detailed.InstanceLocation, "/") - path = strings.ReplaceAll(path, "/", ".") + // Convert JSON Pointer format to bracket notation to match semantic validation format + path := convertJSONPointerToBracketNotation(detailed.InstanceLocation) // Clean up the error message message := detailed.Error diff --git a/internal/validators/schema_test.go b/internal/validators/schema_test.go new file mode 100644 index 000000000..e065376fe --- /dev/null +++ b/internal/validators/schema_test.go @@ -0,0 +1,120 @@ +package validators + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConvertJSONPointerToBracketNotation(t *testing.T) { + tests := []struct { + name string + jsonPointer string + expectedOutput string + description string + }{ + { + name: "single array index in middle", + jsonPointer: "/packages/0/transport", + expectedOutput: "packages[0].transport", + description: "JSON Pointer with single array index converts to bracket notation", + }, + { + name: "multiple array indices", + jsonPointer: "/packages/0/transport/1/url", + expectedOutput: "packages[0].transport[1].url", + description: "JSON Pointer with multiple array indices converts correctly", + }, + { + name: "leading array index", + jsonPointer: "/0/name", + expectedOutput: "[0].name", + description: "JSON Pointer starting with array index converts to leading bracket", + }, + { + name: "trailing array index", + jsonPointer: "/packages/0", + expectedOutput: "packages[0]", + description: "JSON Pointer ending with array index converts correctly", + }, + { + name: "no array indices", + jsonPointer: "/name/version", + expectedOutput: "name.version", + description: "JSON Pointer without array indices converts to dot notation only", + }, + { + name: "complex nested path", + jsonPointer: "/packages/0/runtimeArguments/1/name", + expectedOutput: "packages[0].runtimeArguments[1].name", + description: "Complex nested JSON Pointer with multiple indices converts correctly", + }, + { + name: "multiple consecutive indices", + jsonPointer: "/a/0/1/2", + expectedOutput: "a[0][1][2]", + description: "JSON Pointer with consecutive array indices converts to consecutive brackets", + }, + { + name: "single character path with index", + jsonPointer: "/a/0", + expectedOutput: "a[0]", + description: "Simple JSON Pointer with single field and index converts correctly", + }, + { + name: "empty string", + jsonPointer: "", + expectedOutput: "", + description: "Empty JSON Pointer returns empty string", + }, + { + name: "root path", + jsonPointer: "/", + expectedOutput: "", + description: "Root JSON Pointer (just slash) converts to empty string", + }, + { + name: "only index", + jsonPointer: "/0", + expectedOutput: "[0]", + description: "JSON Pointer with only array index converts to bracket notation", + }, + { + name: "two digit index", + jsonPointer: "/packages/10/transport", + expectedOutput: "packages[10].transport", + description: "JSON Pointer with multi-digit array index converts correctly", + }, + { + name: "three digit index", + jsonPointer: "/packages/123/transport", + expectedOutput: "packages[123].transport", + description: "JSON Pointer with three-digit array index converts correctly", + }, + { + name: "remotes array index", + jsonPointer: "/remotes/0/url", + expectedOutput: "remotes[0].url", + description: "JSON Pointer for remotes array converts correctly", + }, + { + name: "package arguments nested", + jsonPointer: "/packages/0/packageArguments/0/format", + expectedOutput: "packages[0].packageArguments[0].format", + description: "JSON Pointer with nested array structures converts correctly", + }, + { + name: "repository url", + jsonPointer: "/repository/url", + expectedOutput: "repository.url", + description: "JSON Pointer without array indices converts to simple dot notation", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertJSONPointerToBracketNotation(tt.jsonPointer) + assert.Equal(t, tt.expectedOutput, result, "%s: JSON Pointer format should convert to bracket notation", tt.description) + }) + } +} diff --git a/internal/validators/schemas/README.md b/internal/validators/schemas/README.md new file mode 100644 index 000000000..33f1e6894 --- /dev/null +++ b/internal/validators/schemas/README.md @@ -0,0 +1,23 @@ +# Schema Files + +This directory contains JSON Schema files that are embedded into the Go binary (using the `go:embed` directive) for runtime validation. + +## How Schema Files Get Here + +Schema files are automatically synced from the [modelcontextprotocol/static](https://github.com/modelcontextprotocol/static) repository via the GitHub Actions workflow `.github/workflows/sync-schema.yml`. + +The workflow: +1. Checks out the `modelcontextprotocol/static` repository +2. Copies all versioned schema files from `static-repo/schemas/*/server.schema.json` +3. Saves them here as `{version}.json` (e.g., `2025-10-17.json`) +4. Automatically commits and pushes any new or updated schemas + +**Do not manually edit files in this directory** - they are managed by the sync workflow. + +## Usage + +These schema files are embedded into the Go binary using the `go:embed` directive for offline schema validation. The embedded schemas are used by the validation code in `internal/validators/schema.go` to validate `server.json` files against their specified schema version. + +## File Naming + +Files are named `{YYYY-MM-DD}.json` where the date corresponds to the schema version (e.g., `2025-10-17.json`). This matches the version in the schema's `$id` field. diff --git a/internal/validators/validation_detailed_test.go b/internal/validators/validation_detailed_test.go index 8e7078bc6..32a2d2344 100644 --- a/internal/validators/validation_detailed_test.go +++ b/internal/validators/validation_detailed_test.go @@ -1,6 +1,7 @@ package validators_test import ( + "strings" "testing" "github.com/modelcontextprotocol/registry/internal/validators" @@ -9,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestValidateServerJSONExhaustive_CollectsAllErrors(t *testing.T) { +func TestValidateServerJSON_CollectsAllErrors(t *testing.T) { // Create a server JSON with multiple validation errors serverJSON := &apiv0.ServerJSON{ Name: "invalid-name", // Invalid server name format @@ -47,7 +48,7 @@ func TestValidateServerJSONExhaustive_CollectsAllErrors(t *testing.T) { } // Run detailed validation - result := validators.ValidateServerJSONExhaustive(serverJSON, false) + result := validators.ValidateServerJSON(serverJSON, validators.ValidationSchemaVersionAndSemantic) // Verify it's invalid assert.False(t, result.Valid) @@ -99,7 +100,7 @@ func TestValidateServerJSONExhaustive_CollectsAllErrors(t *testing.T) { assert.Greater(t, foundPaths, 5, "Should have issues at multiple JSON paths") } -func TestValidateServerJSONExhaustive_ValidServer(t *testing.T) { +func TestValidateServerJSON_ValidServer(t *testing.T) { // Create a valid server JSON serverJSON := &apiv0.ServerJSON{ Schema: model.CurrentSchemaURL, @@ -125,14 +126,14 @@ func TestValidateServerJSONExhaustive_ValidServer(t *testing.T) { } // Run detailed validation - result := validators.ValidateServerJSONExhaustive(serverJSON, false) + result := validators.ValidateServerJSON(serverJSON, validators.ValidationSchemaVersionAndSemantic) // Verify it's valid assert.True(t, result.Valid) assert.Empty(t, result.Issues, "Should have no validation issues") } -func TestValidateServerJSONExhaustive_ContextPaths(t *testing.T) { +func TestValidateServerJSON_ContextPaths(t *testing.T) { // Create a server with nested validation errors to test context paths serverJSON := &apiv0.ServerJSON{ Name: "com.example.test/server", @@ -166,7 +167,7 @@ func TestValidateServerJSONExhaustive_ContextPaths(t *testing.T) { } // Run detailed validation - result := validators.ValidateServerJSONExhaustive(serverJSON, false) + result := validators.ValidateServerJSON(serverJSON, validators.ValidationSchemaVersionAndSemantic) // Verify we have issues at the correct paths issuePaths := make(map[string]bool) @@ -179,7 +180,7 @@ func TestValidateServerJSONExhaustive_ContextPaths(t *testing.T) { assert.True(t, issuePaths["packages[1].runtimeArguments[0].name"], "Should have issue at packages[1].runtimeArguments[0].name") } -func TestValidateServerJSONExhaustive_RefResolution(t *testing.T) { +func TestValidateServerJSON_RefResolution(t *testing.T) { // Create a server JSON with validation errors that will trigger $ref resolution serverJSON := &apiv0.ServerJSON{ Schema: model.CurrentSchemaURL, @@ -216,7 +217,7 @@ func TestValidateServerJSONExhaustive_RefResolution(t *testing.T) { } // Run validation with schema validation enabled - result := validators.ValidateServerJSONExhaustive(serverJSON, true) + result := validators.ValidateServerJSON(serverJSON, validators.ValidationAll) // Check that we have validation errors assert.False(t, result.Valid, "Expected validation errors") @@ -235,7 +236,7 @@ func TestValidateServerJSONExhaustive_RefResolution(t *testing.T) { expectedRef := "#/definitions/Repository/properties/url/format from: [#/definitions/ServerDetail]/properties/repository/[#/definitions/Repository]/properties/url/format" assert.Equal(t, expectedRef, issue.Reference, "Repository URL error should have exact resolved reference") } - if issue.Path == "packages.0.packageArguments.0.format" { + if issue.Path == "packages[0].packageArguments[0].format" { // The schema uses anyOf for Argument types, so it could match either PositionalArgument or NamedArgument // Just check that it contains the expected definitions assert.Contains(t, issue.Reference, "#/definitions/Input/properties/format/enum", "Should reference the Input format enum") @@ -254,5 +255,191 @@ func TestValidateServerJSONExhaustive_RefResolution(t *testing.T) { // Should have issues at specific paths that trigger $ref resolution assert.True(t, issuePaths["repository.url"], "Should have issue at repository.url") - assert.True(t, issuePaths["packages.0.packageArguments.0.format"], "Should have issue at packages.0.packageArguments.0.format") + assert.True(t, issuePaths["packages[0].packageArguments[0].format"], "Should have issue at packages[0].packageArguments[0].format") +} + +func TestValidateServerJSON_EmptySchema(t *testing.T) { + // Test that empty/missing schema produces an error + serverJSON := &apiv0.ServerJSON{ + // Schema field intentionally omitted (empty string) + Name: "com.example.test/server", + Version: "1.0.0", + Description: "Test server", + Repository: &model.Repository{ + URL: "https://github.com/example/server", + Source: "github", + }, + } + + result := validators.ValidateServerJSON(serverJSON, validators.ValidationAll) + + // Should be invalid due to missing schema + assert.False(t, result.Valid, "Empty schema should cause validation failure") + + // Should have an error issue for missing schema + hasSchemaError := false + for _, issue := range result.Issues { + if issue.Path == "schema" && issue.Severity == validators.ValidationIssueSeverityError { + if strings.Contains(issue.Message, "$schema field is required") { + hasSchemaError = true + } + } + } + assert.True(t, hasSchemaError, "Should have error for missing $schema field") +} + +func TestValidateServerJSON_NonCurrentSchema_Warning(t *testing.T) { + // Test that non-current (but valid) schema produces a warning, not an error + serverJSON := &apiv0.ServerJSON{ + Schema: "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", // Older but valid schema + Name: "com.example.test/server", + Version: "1.0.0", + Description: "Test server", + Repository: &model.Repository{ + URL: "https://github.com/example/server", + Source: "github", + }, + } + + result := validators.ValidateServerJSON(serverJSON, validators.ValidationAll) + + // Should be valid (warnings don't make it invalid) + assert.True(t, result.Valid, "Non-current schema should produce warning but still be valid") + + // Should have a warning issue for non-current schema + hasSchemaWarning := false + for _, issue := range result.Issues { + if issue.Path == "schema" && issue.Severity == validators.ValidationIssueSeverityWarning { + if strings.Contains(issue.Message, "not the current version") || strings.Contains(issue.Message, "Consider updating") { + hasSchemaWarning = true + } + } + } + assert.True(t, hasSchemaWarning, "Should have warning for non-current schema version") +} + +func TestValidateServerJSON_InvalidSchema_Error(t *testing.T) { + // Test that invalid/non-existent schema produces an error + serverJSON := &apiv0.ServerJSON{ + Schema: "https://static.modelcontextprotocol.io/schemas/2025-01-27/server.schema.json", // Non-existent version + Name: "com.example.test/server", + Version: "1.0.0", + Description: "Test server", + Repository: &model.Repository{ + URL: "https://github.com/example/server", + Source: "github", + }, + } + + result := validators.ValidateServerJSON(serverJSON, validators.ValidationAll) + + // Should be invalid due to schema not available + assert.False(t, result.Valid, "Invalid schema version should cause validation failure") + + // Should have an error issue for schema not available + hasSchemaError := false + for _, issue := range result.Issues { + if issue.Path == "schema" && issue.Severity == validators.ValidationIssueSeverityError { + if strings.Contains(issue.Message, "not available") || strings.Contains(issue.Message, "not found") { + hasSchemaError = true + } + } + } + assert.True(t, hasSchemaError, "Should have error for invalid/non-existent schema version") +} + +func TestValidateServerJSON_NonCurrentSchema_Policies(t *testing.T) { + // Test all three policies for non-current schema handling + serverJSON := &apiv0.ServerJSON{ + Schema: "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", // Older but valid schema + Name: "com.example.test/server", + Version: "1.0.0", + Description: "Test server", + Repository: &model.Repository{ + URL: "https://github.com/example/server", + Source: "github", + }, + } + + tests := []struct { + name string + policy validators.SchemaVersionPolicy + expectValid bool + expectWarning bool + expectError bool + expectIssueCount int + }{ + { + name: "Allow policy - no warning or error", + policy: validators.SchemaVersionPolicyAllow, + expectValid: true, + expectWarning: false, + expectError: false, + expectIssueCount: 0, + }, + { + name: "Warn policy - warning but still valid", + policy: validators.SchemaVersionPolicyWarn, + expectValid: true, + expectWarning: true, + expectError: false, + expectIssueCount: 1, + }, + { + name: "Error policy - error and invalid", + policy: validators.SchemaVersionPolicyError, + expectValid: false, + expectWarning: false, + expectError: true, + expectIssueCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := validators.ValidationOptions{ + ValidateSchema: true, + ValidateSemantic: true, + NonCurrentSchemaPolicy: tt.policy, + } + result := validators.ValidateServerJSON(serverJSON, opts) + + assert.Equal(t, tt.expectValid, result.Valid, "Validation result should match expected") + + hasWarning := false + hasError := false + schemaWarnings := 0 + schemaErrors := 0 + + for _, issue := range result.Issues { + if issue.Path == "schema" { + if issue.Severity == validators.ValidationIssueSeverityWarning { + hasWarning = true + schemaWarnings++ + if !strings.Contains(issue.Message, "not the current version") { + t.Errorf("Warning message should mention 'not the current version', got: %s", issue.Message) + } + } + if issue.Severity == validators.ValidationIssueSeverityError { + if strings.Contains(issue.Message, "not the current version") { + hasError = true + schemaErrors++ + } + } + } + } + + assert.Equal(t, tt.expectWarning, hasWarning, "Warning presence should match expected") + assert.Equal(t, tt.expectError, hasError, "Error presence should match expected") + + // Count schema-related issues (excluding other validation issues) + schemaIssueCount := 0 + for _, issue := range result.Issues { + if issue.Path == "schema" && (strings.Contains(issue.Message, "not the current version") || strings.Contains(issue.Message, "current version")) { + schemaIssueCount++ + } + } + assert.Equal(t, tt.expectIssueCount, schemaIssueCount, "Schema issue count should match expected") + }) + } } diff --git a/internal/validators/validation_types.go b/internal/validators/validation_types.go index dfd1a4189..4de21b49b 100644 --- a/internal/validators/validation_types.go +++ b/internal/validators/validation_types.go @@ -21,6 +21,55 @@ const ( ValidationIssueSeverityInfo ValidationIssueSeverity = "info" ) +// SchemaVersionPolicy determines how non-current schema versions are handled +type SchemaVersionPolicy string + +const ( + // SchemaVersionPolicyAllow allows non-current schemas with no warning or error + SchemaVersionPolicyAllow SchemaVersionPolicy = "allow" + // SchemaVersionPolicyWarn allows non-current schemas but generates a warning + SchemaVersionPolicyWarn SchemaVersionPolicy = "warn" + // SchemaVersionPolicyError rejects non-current schemas with an error + SchemaVersionPolicyError SchemaVersionPolicy = "error" +) + +// ValidationOptions configures which types of validation to perform +// ValidateSchema implies ValidateSchemaVersion (the flag is ignored if ValidateSchema is true) +type ValidationOptions struct { + ValidateSchemaVersion bool // Check schema version (empty, non-current). Ignored if ValidateSchema is true. + ValidateSchema bool // Perform full schema validation (implies ValidateSchemaVersion) + ValidateSemantic bool // Perform semantic validation + NonCurrentSchemaPolicy SchemaVersionPolicy // Policy for non-current schemas (only used when schema validation is performed) +} + +// Common validation configurations +var ( + // ValidationSemanticOnly performs only semantic validation (no schema checks) + ValidationSemanticOnly = ValidationOptions{ + ValidateSemantic: true, + } + + // ValidationSchemaVersionOnly checks schema version only (empty, non-current) + ValidationSchemaVersionOnly = ValidationOptions{ + ValidateSchemaVersion: true, + NonCurrentSchemaPolicy: SchemaVersionPolicyError, + } + + // ValidationSchemaVersionAndSemantic checks schema version and performs semantic validation + ValidationSchemaVersionAndSemantic = ValidationOptions{ + ValidateSchemaVersion: true, + ValidateSemantic: true, + NonCurrentSchemaPolicy: SchemaVersionPolicyWarn, + } + + // ValidationAll performs all validation types (schema version, full schema validation, and semantic) + ValidationAll = ValidationOptions{ + ValidateSchema: true, // Implies ValidateSchemaVersion + ValidateSemantic: true, + NonCurrentSchemaPolicy: SchemaVersionPolicyWarn, + } +) + // ValidationIssue represents a single validation problem type ValidationIssue struct { Type ValidationIssueType `json:"type"` @@ -79,6 +128,20 @@ func (vr *ValidationResult) Merge(other *ValidationResult) { } } +// FirstError returns the first error-level issue as an error, or nil if valid +// This provides backward compatibility for code that expects an error return type +func (vr *ValidationResult) FirstError() error { + if vr.Valid { + return nil + } + for _, issue := range vr.Issues { + if issue.Severity == ValidationIssueSeverityError { + return fmt.Errorf("%s", issue.Message) + } + } + return nil +} + // Field adds a field name to the context path func (ctx *ValidationContext) Field(name string) *ValidationContext { if ctx.path == "" { diff --git a/internal/validators/validators.go b/internal/validators/validators.go index 9a955eda1..5468398f2 100644 --- a/internal/validators/validators.go +++ b/internal/validators/validators.go @@ -6,7 +6,6 @@ import ( "fmt" "net/url" "regexp" - "slices" "strings" "github.com/modelcontextprotocol/registry/internal/config" @@ -52,51 +51,24 @@ var ( dottedVersionLikeRe = regexp.MustCompile(`^\s*(?:v?\d+|x|X|\*)(?:\.(?:\d+|x|X|\*)){1,2}(?:-[0-9A-Za-z.-]+)?\s*$`) ) -func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { - result := ValidateServerJSONExhaustive(serverJSON, false) - if !result.Valid { - // Return the first error issue - for _, issue := range result.Issues { - if issue.Severity == ValidationIssueSeverityError { - return fmt.Errorf("%s", issue.Message) - } - } - } - return nil -} - -// ValidateServerJSONExhaustive performs exhaustive validation and returns all issues found -// If validateSchema is true, it will also validate against server.schema.json -func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON, validateSchema bool) *ValidationResult { +// ValidateServerJSON performs exhaustive validation and returns all issues found +// opts specifies which types of validation to perform. ValidateSchema implies ValidateSchemaVersion. +// Empty schema is always checked and always produces an error when schema validation is performed. +func ValidateServerJSON(serverJSON *apiv0.ServerJSON, opts ValidationOptions) *ValidationResult { result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} ctx := &ValidationContext{} - // Validate schema version is provided and supported - // Note: Schema field is also marked as required in the ServerJSON struct definition - // for API-level validation and documentation - switch { - case serverJSON.Schema == "": - issue := NewValidationIssueFromError( - ValidationIssueTypeSemantic, - ctx.Field("schema").String(), - fmt.Errorf("$schema field is required"), - "schema-field-required", - ) - result.AddIssue(issue) - case !strings.Contains(serverJSON.Schema, model.CurrentSchemaVersion): - issue := NewValidationIssueFromError( - ValidationIssueTypeSemantic, - ctx.Field("schema").String(), - fmt.Errorf("schema version %s is not supported. Please use schema version %s", serverJSON.Schema, model.CurrentSchemaVersion), - "schema-version-not-supported", - ) - result.AddIssue(issue) - case validateSchema: - // We have a valid schema version and validation requested - schemaResult := validateServerJSONSchema(serverJSON) + // Schema validation (version check and/or full validation) + if opts.ValidateSchemaVersion || opts.ValidateSchema { + schemaResult := validateServerJSONSchema(serverJSON, opts.ValidateSchema, opts.NonCurrentSchemaPolicy) result.Merge(schemaResult) } + // Semantic validation (only if requested) + if !opts.ValidateSemantic { + return result + } + // Validate server name exists and format if _, err := parseServerName(*serverJSON); err != nil { issue := NewValidationIssueFromError( @@ -141,14 +113,6 @@ func ValidateServerJSONExhaustive(serverJSON *apiv0.ServerJSON, validateSchema b result.Merge(remoteResult) } - // Validate reverse-DNS namespace matching for remote URLs - remoteNamespaceResult := validateRemoteNamespaceMatch(ctx.Field("remotes"), *serverJSON) - result.Merge(remoteNamespaceResult) - - // Validate reverse-DNS namespace matching for website URL - websiteNamespaceResult := validateWebsiteURLNamespaceMatch(ctx.Field("websiteUrl"), *serverJSON) - result.Merge(websiteNamespaceResult) - return result } @@ -632,22 +596,46 @@ func ValidatePublishRequest(ctx context.Context, req apiv0.ServerJSON, cfg *conf } // Validate the server detail (includes all nested validation) - if err := ValidateServerJSON(&req); err != nil { + result := ValidateServerJSON(&req, ValidationSchemaVersionAndSemantic) + if err := result.FirstError(); err != nil { return err } // Validate registry ownership for all packages if validation is enabled if cfg.EnableRegistryValidation { - for i, pkg := range req.Packages { - if err := ValidatePackage(ctx, pkg, req.Name); err != nil { - return fmt.Errorf("registry validation failed for package %d (%s): %w", i, pkg.Identifier, err) - } + if err := validateRegistryOwnership(ctx, req); err != nil { + return err } } return nil } +func ValidateUpdateRequest(ctx context.Context, req apiv0.ServerJSON, cfg *config.Config, skipRegistryValidation bool) error { + // Validate the server detail (includes all nested validation) + result := ValidateServerJSON(&req, ValidationSchemaVersionAndSemantic) + if err := result.FirstError(); err != nil { + return err + } + + if cfg.EnableRegistryValidation && !skipRegistryValidation { + if err := validateRegistryOwnership(ctx, req); err != nil { + return err + } + } + + return nil +} + +func validateRegistryOwnership(ctx context.Context, req apiv0.ServerJSON) error { + for i, pkg := range req.Packages { + if err := ValidatePackage(ctx, pkg, req.Name); err != nil { + return fmt.Errorf("registry validation failed for package %d (%s): %w", i, pkg.Identifier, err) + } + } + return nil +} + func validatePublisherExtensions(req apiv0.ServerJSON) error { const maxExtensionSize = 4 * 1024 // 4KB limit @@ -709,114 +697,3 @@ func parseServerName(serverJSON apiv0.ServerJSON) (string, error) { return name, nil } - -// validateRemoteNamespaceMatch validates that remote URLs match the reverse-DNS namespace -func validateRemoteNamespaceMatch(ctx *ValidationContext, serverJSON apiv0.ServerJSON) *ValidationResult { - result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} - namespace := serverJSON.Name - - for i, remote := range serverJSON.Remotes { - if err := validateRemoteURLMatchesNamespace(remote.URL, namespace); err != nil { - issue := NewValidationIssueFromError( - ValidationIssueTypeSemantic, - ctx.Index(i).Field("url").String(), - fmt.Errorf("remote URL %s does not match namespace %s: %w", remote.URL, namespace, err), - "remote-url-namespace-mismatch", - ) - result.AddIssue(issue) - } - } - - return result -} - -// validateWebsiteURLNamespaceMatch validates that website URL matches the reverse-DNS namespace -func validateWebsiteURLNamespaceMatch(ctx *ValidationContext, serverJSON apiv0.ServerJSON) *ValidationResult { - result := &ValidationResult{Valid: true, Issues: []ValidationIssue{}} - - // Skip validation if website URL is not provided - if serverJSON.WebsiteURL == "" { - return result - } - - namespace := serverJSON.Name - if err := validateRemoteURLMatchesNamespace(serverJSON.WebsiteURL, namespace); err != nil { - issue := NewValidationIssueFromError( - ValidationIssueTypeSemantic, - ctx.String(), - fmt.Errorf("websiteUrl %s does not match namespace %s: %w", serverJSON.WebsiteURL, namespace, err), - "website-url-namespace-mismatch", - ) - result.AddIssue(issue) - } - - return result -} - -// 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 60585d0fe..e12ab06ad 100644 --- a/internal/validators/validators_test.go +++ b/internal/validators/validators_test.go @@ -44,10 +44,9 @@ func TestValidate(t *testing.T) { }, Version: "1.0.0", }, - // This schema version doesn't exist in embedded schemas, so validation will fail - // Note: ValidateServerJSON doesn't enable schema validation by default, - // so this test now just checks that basic validation passes (schema field is present) - expectedError: "", + // This schema version doesn't exist in embedded schemas, so validation should fail + // ValidateServerJSON with ValidationSchemaVersionAndSemantic validates that the schema version exists + expectedError: "schema version 2025-01-27 not found in embedded schemas", }, { name: "Schema version accepts current schema (2025-10-17)", @@ -753,7 +752,10 @@ func TestValidate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validators.ValidateServerJSON(&tt.serverDetail) + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior + // TODO: In future, consider using result.Issues for comprehensive error reporting + result := validators.ValidateServerJSON(&tt.serverDetail, validators.ValidationSchemaVersionAndSemantic) + err := result.FirstError() if tt.expectedError == "" { assert.NoError(t, err) @@ -908,7 +910,10 @@ func TestValidate_RemoteNamespaceMatch(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validators.ValidateServerJSON(&tt.serverDetail) + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior + // TODO: In future, consider using result.Issues for comprehensive error reporting + result := validators.ValidateServerJSON(&tt.serverDetail, validators.ValidationSchemaVersionAndSemantic) + err := result.FirstError() if tt.expectError { assert.Error(t, err) @@ -992,7 +997,10 @@ func TestValidate_ServerNameFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validators.ValidateServerJSON(&tt.serverDetail) + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior + // TODO: In future, consider using result.Issues for comprehensive error reporting + result := validators.ValidateServerJSON(&tt.serverDetail, validators.ValidationSchemaVersionAndSemantic) + err := result.FirstError() if tt.expectError { assert.Error(t, err) @@ -1071,7 +1079,10 @@ func TestValidate_MultipleSlashesInServerName(t *testing.T) { Schema: model.CurrentSchemaURL, Name: tt.serverName, } - err := validators.ValidateServerJSON(&serverDetail) + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior + // TODO: In future, consider using result.Issues for comprehensive error reporting + result := validators.ValidateServerJSON(&serverDetail, validators.ValidationSchemaVersionAndSemantic) + err := result.FirstError() if tt.expectError { assert.Error(t, err) @@ -1125,7 +1136,10 @@ func TestValidateArgument_ValidNamedArguments(t *testing.T) { for _, arg := range validCases { t.Run("Valid_"+arg.Name, func(t *testing.T) { server := createValidServerWithArgument(arg) - err := validators.ValidateServerJSON(&server) + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior + // TODO: In future, consider using result.Issues for comprehensive error reporting + result := validators.ValidateServerJSON(&server, validators.ValidationSchemaVersionAndSemantic) + err := result.FirstError() assert.NoError(t, err, "Expected valid argument %+v", arg) }) } @@ -1144,7 +1158,10 @@ func TestValidateArgument_ValidPositionalArguments(t *testing.T) { for i, arg := range positionalCases { t.Run(fmt.Sprintf("ValidPositional_%d", i), func(t *testing.T) { server := createValidServerWithArgument(arg) - err := validators.ValidateServerJSON(&server) + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior + // TODO: In future, consider using result.Issues for comprehensive error reporting + result := validators.ValidateServerJSON(&server, validators.ValidationSchemaVersionAndSemantic) + err := result.FirstError() assert.NoError(t, err, "Expected valid positional argument %+v", arg) }) } @@ -1166,7 +1183,10 @@ func TestValidateArgument_InvalidNamedArgumentNames(t *testing.T) { for _, tc := range invalidNameCases { t.Run("Invalid_"+tc.name, func(t *testing.T) { server := createValidServerWithArgument(tc.arg) - err := validators.ValidateServerJSON(&server) + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior + // TODO: In future, consider using result.Issues for comprehensive error reporting + result := validators.ValidateServerJSON(&server, validators.ValidationSchemaVersionAndSemantic) + err := result.FirstError() assert.Error(t, err, "Expected error for invalid named argument name: %+v", tc.arg) }) } @@ -1214,7 +1234,10 @@ func TestValidateArgument_InvalidValueFields(t *testing.T) { for _, tc := range invalidValueCases { t.Run("Invalid_"+tc.name, func(t *testing.T) { server := createValidServerWithArgument(tc.arg) - err := validators.ValidateServerJSON(&server) + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior + // TODO: In future, consider using result.Issues for comprehensive error reporting + result := validators.ValidateServerJSON(&server, validators.ValidationSchemaVersionAndSemantic) + err := result.FirstError() assert.Error(t, err, "Expected error for argument with value starting with name: %+v", tc.arg) }) } @@ -1270,7 +1293,10 @@ func TestValidateArgument_ValidValueFields(t *testing.T) { for _, tc := range validValueCases { t.Run("Valid_"+tc.name, func(t *testing.T) { server := createValidServerWithArgument(tc.arg) - err := validators.ValidateServerJSON(&server) + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior + // TODO: In future, consider using result.Issues for comprehensive error reporting + result := validators.ValidateServerJSON(&server, validators.ValidationSchemaVersionAndSemantic) + err := result.FirstError() assert.NoError(t, err, "Expected valid argument %+v", tc.arg) }) } @@ -1605,7 +1631,10 @@ func TestValidate_TransportValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validators.ValidateServerJSON(&tt.serverDetail) + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior + // TODO: In future, consider using result.Issues for comprehensive error reporting + result := validators.ValidateServerJSON(&tt.serverDetail, validators.ValidationSchemaVersionAndSemantic) + err := result.FirstError() if tt.expectedError == "" { assert.NoError(t, err) @@ -2034,7 +2063,10 @@ func TestValidateTitle(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validators.ValidateServerJSON(&tt.serverDetail) + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior + // TODO: In future, consider using result.Issues for comprehensive error reporting + result := validators.ValidateServerJSON(&tt.serverDetail, validators.ValidationSchemaVersionAndSemantic) + err := result.FirstError() if tt.expectedError == "" { assert.NoError(t, err) } else { diff --git a/tools/validate-examples/main.go b/tools/validate-examples/main.go index 41d8ba051..b28c01387 100644 --- a/tools/validate-examples/main.go +++ b/tools/validate-examples/main.go @@ -168,7 +168,10 @@ func validateWithObjectValidator(serverData any) bool { return false } - if err := validators.ValidateServerJSON(&serverDetail); err != nil { + // ValidateServerJSON returns all validation results; using FirstError() to preserve existing behavior + // TODO: In future, consider displaying all issues from result.Issues for comprehensive feedback + result := validators.ValidateServerJSON(&serverDetail, validators.ValidationSchemaVersionAndSemantic) + if err := result.FirstError(); err != nil { log.Printf(" Validating with Go Validator: ❌") log.Printf(" Error: %v", err) return false From d7968ae2ed71ef826ff546978616097869eca213 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 10 Dec 2025 22:20:37 -0800 Subject: [PATCH 18/18] Linter fixes --- cmd/publisher/commands/publish.go | 2 +- cmd/publisher/commands/validate.go | 15 ++++++------- internal/importer/importer.go | 2 +- internal/validators/schema.go | 6 ++--- internal/validators/schema_test.go | 5 +++-- .../validators/validation_detailed_test.go | 12 +++++----- internal/validators/validators_test.go | 22 +++++++++---------- tools/validate-examples/main.go | 2 +- 8 files changed, 34 insertions(+), 32 deletions(-) diff --git a/cmd/publisher/commands/publish.go b/cmd/publisher/commands/publish.go index 5b1dc7669..917fa3299 100644 --- a/cmd/publisher/commands/publish.go +++ b/cmd/publisher/commands/publish.go @@ -41,7 +41,7 @@ func PublishCommand(args []string) error { // Validate schema version (non-empty schema, valid schema, and current schema) // This performs schema version checks without full schema validation // Note: When we enable full validation, use validators.ValidationAll instead - result, _ := runValidationAndPrintIssues(&serverJSON, validators.ValidationSchemaVersionOnly) + result := runValidationAndPrintIssues(&serverJSON, validators.ValidationSchemaVersionOnly) if !result.Valid { // Return error after printing (all errors already printed by validateServerJSON) return fmt.Errorf("validation failed") diff --git a/cmd/publisher/commands/validate.go b/cmd/publisher/commands/validate.go index af02b2c96..41f227529 100644 --- a/cmd/publisher/commands/validate.go +++ b/cmd/publisher/commands/validate.go @@ -64,15 +64,14 @@ func printSchemaValidationErrors(result *validators.ValidationResult, serverJSON // runValidationAndPrintIssues validates the server JSON, prints schema validation errors, and prints all issues. // Validation failures are always printed (for both validate and publish commands). -// Returns the validation result and whether schema errors were printed. -func runValidationAndPrintIssues(serverJSON *apiv0.ServerJSON, opts validators.ValidationOptions) (*validators.ValidationResult, bool) { +func runValidationAndPrintIssues(serverJSON *apiv0.ServerJSON, opts validators.ValidationOptions) *validators.ValidationResult { result := validators.ValidateServerJSON(serverJSON, opts) // Print schema validation errors/warnings with friendly messages - schemaPrinted := printSchemaValidationErrors(result, serverJSON) + printSchemaValidationErrors(result, serverJSON) if result.Valid { - return result, schemaPrinted + return result } // Print all issues @@ -83,8 +82,8 @@ func runValidationAndPrintIssues(serverJSON *apiv0.ServerJSON, opts validators.V issueNum := 1 for _, issue := range result.Issues { - // Skip schema issues that were already printed - if (issue.Reference == "schema-field-required" || issue.Reference == "schema-version-deprecated") && schemaPrinted { + // Skip schema issues that were already printed (they're printed by printSchemaValidationErrors above) + if issue.Reference == "schema-field-required" || issue.Reference == "schema-version-deprecated" { continue } @@ -98,7 +97,7 @@ func runValidationAndPrintIssues(serverJSON *apiv0.ServerJSON, opts validators.V issueNum++ } - return result, schemaPrinted + return result } func ValidateCommand(args []string) error { @@ -141,7 +140,7 @@ func ValidateCommand(args []string) error { // Run detailed validation (this is the whole point of the validate command) // Include schema validation for comprehensive validation // Warn about non-current schemas (don't error, just inform) - result, _ := runValidationAndPrintIssues(&serverJSON, validators.ValidationAll) + result := runValidationAndPrintIssues(&serverJSON, validators.ValidationAll) if result.Valid { _, _ = fmt.Fprintln(os.Stdout, "✅ server.json is valid") diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 84b5817ea..4a418d184 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -100,7 +100,7 @@ func readSeedFile(ctx context.Context, path string) ([]*apiv0.ServerJSON, error) for _, response := range serverResponses { // ValidateServerJSON returns all validation results; using FirstError() to preserve existing behavior - // TODO: In future, consider logging all issues from result.Issues for better diagnostics + // In future, consider logging all issues from result.Issues for better diagnostics result := validators.ValidateServerJSON(&response, validators.ValidationSchemaVersionAndSemantic) if err := result.FirstError(); err != nil { // Log warning and track invalid server instead of failing diff --git a/internal/validators/schema.go b/internal/validators/schema.go index a9b0981f6..8a64b32fb 100644 --- a/internal/validators/schema.go +++ b/internal/validators/schema.go @@ -254,7 +254,7 @@ func addValidationError(result *ValidationResult, validationErr *jsonschema.Vali addDetailedErrors(result, detailed, schema) } -// convertJSONPointerToBracketNotation converts a JSON Pointer path (RFC 6901) to bracket notation +// ConvertJSONPointerToBracketNotation converts a JSON Pointer path (RFC 6901) to bracket notation // format to match the format used by semantic validation (ValidationContext). // The transformation includes: // 1. Remove leading slash from JSON Pointer format @@ -263,7 +263,7 @@ func addValidationError(result *ValidationResult, validationErr *jsonschema.Vali // Example: "/packages/0/transport" -> "packages[0].transport" // Example: "/0/name" -> "[0].name" // Example: "/packages/0/transport/1/url" -> "packages[0].transport[1].url" -func convertJSONPointerToBracketNotation(jsonPointer string) string { +func ConvertJSONPointerToBracketNotation(jsonPointer string) string { if jsonPointer == "" { return "" } @@ -317,7 +317,7 @@ func addDetailedErrors(result *ValidationResult, detailed jsonschema.Detailed, s // Only process errors that have specific field paths and meaningful messages if detailed.InstanceLocation != "" && detailed.Error != "" { // Convert JSON Pointer format to bracket notation to match semantic validation format - path := convertJSONPointerToBracketNotation(detailed.InstanceLocation) + path := ConvertJSONPointerToBracketNotation(detailed.InstanceLocation) // Clean up the error message message := detailed.Error diff --git a/internal/validators/schema_test.go b/internal/validators/schema_test.go index e065376fe..61768e4fe 100644 --- a/internal/validators/schema_test.go +++ b/internal/validators/schema_test.go @@ -1,8 +1,9 @@ -package validators +package validators_test import ( "testing" + "github.com/modelcontextprotocol/registry/internal/validators" "github.com/stretchr/testify/assert" ) @@ -113,7 +114,7 @@ func TestConvertJSONPointerToBracketNotation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := convertJSONPointerToBracketNotation(tt.jsonPointer) + result := validators.ConvertJSONPointerToBracketNotation(tt.jsonPointer) assert.Equal(t, tt.expectedOutput, result, "%s: JSON Pointer format should convert to bracket notation", tt.description) }) } diff --git a/internal/validators/validation_detailed_test.go b/internal/validators/validation_detailed_test.go index 32a2d2344..fc6117827 100644 --- a/internal/validators/validation_detailed_test.go +++ b/internal/validators/validation_detailed_test.go @@ -10,6 +10,8 @@ import ( "github.com/stretchr/testify/assert" ) +const schemaPath = "schema" + func TestValidateServerJSON_CollectsAllErrors(t *testing.T) { // Create a server JSON with multiple validation errors serverJSON := &apiv0.ServerJSON{ @@ -279,7 +281,7 @@ func TestValidateServerJSON_EmptySchema(t *testing.T) { // Should have an error issue for missing schema hasSchemaError := false for _, issue := range result.Issues { - if issue.Path == "schema" && issue.Severity == validators.ValidationIssueSeverityError { + if issue.Path == schemaPath && issue.Severity == validators.ValidationIssueSeverityError { if strings.Contains(issue.Message, "$schema field is required") { hasSchemaError = true } @@ -309,7 +311,7 @@ func TestValidateServerJSON_NonCurrentSchema_Warning(t *testing.T) { // Should have a warning issue for non-current schema hasSchemaWarning := false for _, issue := range result.Issues { - if issue.Path == "schema" && issue.Severity == validators.ValidationIssueSeverityWarning { + if issue.Path == schemaPath && issue.Severity == validators.ValidationIssueSeverityWarning { if strings.Contains(issue.Message, "not the current version") || strings.Contains(issue.Message, "Consider updating") { hasSchemaWarning = true } @@ -339,7 +341,7 @@ func TestValidateServerJSON_InvalidSchema_Error(t *testing.T) { // Should have an error issue for schema not available hasSchemaError := false for _, issue := range result.Issues { - if issue.Path == "schema" && issue.Severity == validators.ValidationIssueSeverityError { + if issue.Path == schemaPath && issue.Severity == validators.ValidationIssueSeverityError { if strings.Contains(issue.Message, "not available") || strings.Contains(issue.Message, "not found") { hasSchemaError = true } @@ -412,7 +414,7 @@ func TestValidateServerJSON_NonCurrentSchema_Policies(t *testing.T) { schemaErrors := 0 for _, issue := range result.Issues { - if issue.Path == "schema" { + if issue.Path == schemaPath { if issue.Severity == validators.ValidationIssueSeverityWarning { hasWarning = true schemaWarnings++ @@ -435,7 +437,7 @@ func TestValidateServerJSON_NonCurrentSchema_Policies(t *testing.T) { // Count schema-related issues (excluding other validation issues) schemaIssueCount := 0 for _, issue := range result.Issues { - if issue.Path == "schema" && (strings.Contains(issue.Message, "not the current version") || strings.Contains(issue.Message, "current version")) { + if issue.Path == schemaPath && (strings.Contains(issue.Message, "not the current version") || strings.Contains(issue.Message, "current version")) { schemaIssueCount++ } } diff --git a/internal/validators/validators_test.go b/internal/validators/validators_test.go index e12ab06ad..6e379741d 100644 --- a/internal/validators/validators_test.go +++ b/internal/validators/validators_test.go @@ -753,7 +753,7 @@ func TestValidate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior - // TODO: In future, consider using result.Issues for comprehensive error reporting + // In future, consider using result.Issues for comprehensive error reporting result := validators.ValidateServerJSON(&tt.serverDetail, validators.ValidationSchemaVersionAndSemantic) err := result.FirstError() @@ -911,7 +911,7 @@ func TestValidate_RemoteNamespaceMatch(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior - // TODO: In future, consider using result.Issues for comprehensive error reporting + // In future, consider using result.Issues for comprehensive error reporting result := validators.ValidateServerJSON(&tt.serverDetail, validators.ValidationSchemaVersionAndSemantic) err := result.FirstError() @@ -998,7 +998,7 @@ func TestValidate_ServerNameFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior - // TODO: In future, consider using result.Issues for comprehensive error reporting + // In future, consider using result.Issues for comprehensive error reporting result := validators.ValidateServerJSON(&tt.serverDetail, validators.ValidationSchemaVersionAndSemantic) err := result.FirstError() @@ -1080,7 +1080,7 @@ func TestValidate_MultipleSlashesInServerName(t *testing.T) { Name: tt.serverName, } // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior - // TODO: In future, consider using result.Issues for comprehensive error reporting + // In future, consider using result.Issues for comprehensive error reporting result := validators.ValidateServerJSON(&serverDetail, validators.ValidationSchemaVersionAndSemantic) err := result.FirstError() @@ -1137,7 +1137,7 @@ func TestValidateArgument_ValidNamedArguments(t *testing.T) { t.Run("Valid_"+arg.Name, func(t *testing.T) { server := createValidServerWithArgument(arg) // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior - // TODO: In future, consider using result.Issues for comprehensive error reporting + // In future, consider using result.Issues for comprehensive error reporting result := validators.ValidateServerJSON(&server, validators.ValidationSchemaVersionAndSemantic) err := result.FirstError() assert.NoError(t, err, "Expected valid argument %+v", arg) @@ -1159,7 +1159,7 @@ func TestValidateArgument_ValidPositionalArguments(t *testing.T) { t.Run(fmt.Sprintf("ValidPositional_%d", i), func(t *testing.T) { server := createValidServerWithArgument(arg) // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior - // TODO: In future, consider using result.Issues for comprehensive error reporting + // In future, consider using result.Issues for comprehensive error reporting result := validators.ValidateServerJSON(&server, validators.ValidationSchemaVersionAndSemantic) err := result.FirstError() assert.NoError(t, err, "Expected valid positional argument %+v", arg) @@ -1184,7 +1184,7 @@ func TestValidateArgument_InvalidNamedArgumentNames(t *testing.T) { t.Run("Invalid_"+tc.name, func(t *testing.T) { server := createValidServerWithArgument(tc.arg) // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior - // TODO: In future, consider using result.Issues for comprehensive error reporting + // In future, consider using result.Issues for comprehensive error reporting result := validators.ValidateServerJSON(&server, validators.ValidationSchemaVersionAndSemantic) err := result.FirstError() assert.Error(t, err, "Expected error for invalid named argument name: %+v", tc.arg) @@ -1235,7 +1235,7 @@ func TestValidateArgument_InvalidValueFields(t *testing.T) { t.Run("Invalid_"+tc.name, func(t *testing.T) { server := createValidServerWithArgument(tc.arg) // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior - // TODO: In future, consider using result.Issues for comprehensive error reporting + // In future, consider using result.Issues for comprehensive error reporting result := validators.ValidateServerJSON(&server, validators.ValidationSchemaVersionAndSemantic) err := result.FirstError() assert.Error(t, err, "Expected error for argument with value starting with name: %+v", tc.arg) @@ -1294,7 +1294,7 @@ func TestValidateArgument_ValidValueFields(t *testing.T) { t.Run("Valid_"+tc.name, func(t *testing.T) { server := createValidServerWithArgument(tc.arg) // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior - // TODO: In future, consider using result.Issues for comprehensive error reporting + // In future, consider using result.Issues for comprehensive error reporting result := validators.ValidateServerJSON(&server, validators.ValidationSchemaVersionAndSemantic) err := result.FirstError() assert.NoError(t, err, "Expected valid argument %+v", tc.arg) @@ -1632,7 +1632,7 @@ func TestValidate_TransportValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior - // TODO: In future, consider using result.Issues for comprehensive error reporting + // In future, consider using result.Issues for comprehensive error reporting result := validators.ValidateServerJSON(&tt.serverDetail, validators.ValidationSchemaVersionAndSemantic) err := result.FirstError() @@ -2064,7 +2064,7 @@ func TestValidateTitle(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // ValidateServerJSON returns all validation results; using FirstError() to preserve existing test behavior - // TODO: In future, consider using result.Issues for comprehensive error reporting + // In future, consider using result.Issues for comprehensive error reporting result := validators.ValidateServerJSON(&tt.serverDetail, validators.ValidationSchemaVersionAndSemantic) err := result.FirstError() if tt.expectedError == "" { diff --git a/tools/validate-examples/main.go b/tools/validate-examples/main.go index b28c01387..103609653 100644 --- a/tools/validate-examples/main.go +++ b/tools/validate-examples/main.go @@ -169,7 +169,7 @@ func validateWithObjectValidator(serverData any) bool { } // ValidateServerJSON returns all validation results; using FirstError() to preserve existing behavior - // TODO: In future, consider displaying all issues from result.Issues for comprehensive feedback + // In future, consider displaying all issues from result.Issues for comprehensive feedback result := validators.ValidateServerJSON(&serverDetail, validators.ValidationSchemaVersionAndSemantic) if err := result.FirstError(); err != nil { log.Printf(" Validating with Go Validator: ❌")