registry/internal/validators/schema.go
Radoslav Dimitrov 0fc4606be2
Stop pinning Go to patch versions, use stable in CI (#1137)
<!-- Provide a brief summary of your changes -->

## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->
We keep bumping `go 1.26.x` across the repo every time a new Go patch
drops
## How Has This Been Tested?
<!-- Have you tested this in a real application? Which scenarios were
tested? -->
- **`go.mod` / `deploy/go.mod`**: `go 1.25.8` → `go 1.26` (minor only —
"this module needs Go 1.26")
- **All CI workflows**: `go-version-file: 'go.mod'` → `go-version:
'stable'` — CI always uses the latest stable Go, no more
  bump PRs
- **govulncheck**: removed hardcoded `go-version-input: '1.26.1'` →
`go-version-input: 'stable'`
## Breaking Changes
<!-- Will users need to update their code or configurations? -->

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [ ] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [ ] My code follows the repository's style guidelines
- [ ] New and existing tests pass locally
- [ ] I have added appropriate error handling
- [ ] I have added or updated documentation as needed

## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->

Signed-off-by: Radoslav Dimitrov <radoslav@stacklok.com>
2026-04-09 11:57:21 -04:00

476 lines
15 KiB
Go

package validators
import (
"bytes"
"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 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
}
// 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
}
// GetCurrentSchemaVersion returns the current schema URL from constants
func GetCurrentSchemaVersion() (string, error) {
return model.CurrentSchemaURL, nil
}
// 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
}
// If not performing validation, return after performing schema version checks (done above)
if !performValidation {
return result
}
// Parse the schema
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(
ValidationIssueTypeSchema,
ctx.Field("schema").String(),
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]any
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
}
// 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(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",
)
result.AddIssue(issue)
return result
}
schemaInstance, err := compiler.Compile(schemaID)
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
var validationErr *jsonschema.ValidationError
if errors.As(err, &validationErr) {
// Process the validation error and its causes
addValidationError(result, validationErr, schema)
} 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, schema map[string]any) {
// Use DetailedOutput to get the nested error details
detailed := validationErr.DetailedOutput()
// Process the detailed error structure
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
fmt.Fprintf(&result, "[%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 format to bracket notation to match semantic validation format
path := ConvertJSONPointerToBracketNotation(detailed.InstanceLocation)
// 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")
}
// Build the full resolved reference path
reference := buildResolvedReference(detailed.KeywordLocation, detailed.AbsoluteKeywordLocation, schema)
issue := NewValidationIssue(
ValidationIssueTypeSchema,
path,
message,
ValidationIssueSeverityError,
reference, // cleaned schema rule path for deterministic mapping
)
result.AddIssue(issue)
}
// Process nested errors
for _, nested := range detailed.Errors {
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 ""
}