registry/internal/validators/schema.go
Bob Dickinson fc88cf0870
Enhanced server.json validation (phase 1) (#636)
Added schema validation and support for exhaustive and more detailed
validation to existing validators. Added new `mcp-publisher validate`
command. No change to any existing tests or behavior other than the new
command.

## Motivation and Context

Many currently published servers fail schema validation. Of those that
pass, many have semantic/logical errors or other errors that make them
impossible to configure, or their configuration when applied doesn't
generate correct MCP server configuration (per their own documentation).

To address this, we need better tooling to validate servers and to
inform server publishers of errors, issues, and best practices in a way
that is clear and actionable, both during server development and at time
of publishing.

I have started a discussion about the broader topic:
https://github.com/modelcontextprotocol/registry/discussions/635

There is also a fairly detailed document describing the design,
architecture, implementation, future plans, etc. See [Enhanced
Validation Design](docs/explanations/proposed-enhanced-validation.md)

This PR is the first step in the process of improving validation. It
adds schema validation and updates all existing validators to use a new
model that allows them to track context and return rich and exhaustive
results. This has been done in a way that is backward compatible
(ValidateServerJSON is unchanged as are all existing validation unit
tests).

There is a new `mcp-publisher` command called `validate` that is
currenty the only place that schema validation and enhanced
(exhaustive/rich) validation results are exposed.

### Changes

Internal Validation improvements
- Schema validation has been implemented and is available as an option
in validation methods (defaults to off)
- Existing validators track the JSON path (context) of the attribute
they're evaluating, passing it to any child validators
- Existing validators are exhaustive (return all issues, not failing
fast on the first error)
- Existing validators return rich validation results, including:
  - type (schema, semantic, linter)
  - severity (error, warn, info)
  - path (JSON path to server.json element)
  - reference (to the schema element or rule that triggered the result)
  - message (currently the same as previous error message)

A new `validate` command has been added to `mcp-publisher` so publishers
can evaluate their server.json before publishing
- Includes new schema validation and previous semantic validation
- Shows full details of all issues encountered during validation

### Usage

Given a server.json that looks like this:

```json
{
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
  "name": "io.github.BobDickinson/registry",
  "description": "An MCP server that provides [describe what your server does]",
  "repository": {
    "url": "",
    "source": ""
  },
  "version": "=1.0.0",
  "packages": [
    {
      "registryType": "oci",
      "registryBaseUrl": "https://docker.io",
      "identifier": "registry",
      "version": "1.0.0",
      "transport": {
        "type": "sse"
      },
      "packageArguments": [
        {
          "type": "named",
          "name": "--mode",
          "description": "Operation mode",
          "format": "foo"
        }
      ],
      "environmentVariables": [
        {
          "description": "Your API key for the service",
          "isRequired": true,
          "format": "string",
          "isSecret": true,
          "name": "YOUR_API_KEY"
        }
      ]
    }
  ]
}
```

Run the command: 

`mcp-publisher validate server.json`

Which will produce the output:

```bash
❌ Validation failed with 5 issue(s):

1. [error] packages.0.packageArguments.0.format (schema)
   value must be one of "string", "number", "boolean", "filepath"
   Reference: #/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

2. [error] packages.0.transport (schema)
   missing required fields: 'url'
   Reference: #/definitions/SseTransport/required from: [#/definitions/ServerDetail]/properties/packages/items/[#/definitions/Package]/properties/transport/else/else/[#/definitions/SseTransport]/required

3. [error] repository.url (schema)
   '' has invalid format 'uri'
   Reference: #/definitions/Repository/properties/url/format from: [#/definitions/ServerDetail]/properties/repository/[#/definitions/Repository]/properties/url/format

4. [error] version (semantic)
   version must be a specific version, not a range: "=1.0.0"
   Reference: version-looks-like-range

5. [error] packages[0].transport.url (semantic)
   url is required for sse transport type
   Reference: streamable-transport-url-required

Error: validation failed
```

Note in the results above:
- All errors contain the JSON path of the triggering element from
server.json.
- The first three errors are schema errors detected during schema
validation. The `Reference` provided contains an absolute path to the
schema rule that was violated, followed by the full schema path that
enforced that rule (with $ref redirect values substited and enclosed in
square brackets).
- The error messages provided by the existing semantic validators are
exactly the same as the previous error messages those validators
produced.

In the final solution we would remove semantic validators that are
redundant to schema validation (as in number 5 above). We have the
option to use the structured data produced by schema validation to
replace the generic schema validation message with a more descriptive
message if that is an issue (in particular, if the only argument for an
ad-hoc validator is that is produces a better/cleaner message, we would
just move that message creation code to the schema error result
formatter and special case it as opposed to having a redundant
validator).

### What's Next

If this general direction is supported and this PR is accepted, a fast
follow would include:

- Add new validators (infliuenced by observed issues) for both semantic
errors and best practices
- Require passing schema validation (in addition to improved semantic
validation) in order to publish
- Remove existing validation code that is redundant to schema validation
(ensuring error messages remain intelligible)
- Update validation client code and tests to handle the new rich return
types natively

### Issues

Schema validation requires embedding the `server.schema.json` file into
the validators package via `go:embed`, which is restricted from
embedding files outside of the package. For this reason we copy the
schema file into a schema subdir (via a prep target in the makefile) and
we .gitignore it. I tried cleaning up the copy in the make clean target,
but then the linter would complain about the embed target being missing
if it wasn't there during development. I also considered just checking
in the schema file and assuming a separate workflow for updating the
embedded schema file, but it's not clear what that workflow would be or
how it would be maintained. I'm open to a different approach, but the
schema file does need to be embedded somehow for schema validation to
work.

## How Has This Been Tested?
All existing validation tests pass, new tests implemented and all pass.

## Breaking Changes
No breaking changes, no behavior change except new `validate` command

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

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-16 00:49:39 +02: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
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 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 ""
}