registry/internal/validators/validation_detailed_test.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

447 lines
15 KiB
Go

package validators_test
import (
"strings"
"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"
)
const schemaPath = "schema"
func TestValidateServerJSON_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 <port>", // Invalid argument name
},
},
},
},
Remotes: []model.Transport{
{
Type: model.TransportTypeStdio, // Invalid remote transport type
URL: "", // Missing URL for remote
},
},
}
// Run detailed validation
result := validators.ValidateServerJSON(serverJSON, validators.ValidationSchemaVersionAndSemantic)
// 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 TestValidateServerJSON_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",
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.ValidateServerJSON(serverJSON, validators.ValidationSchemaVersionAndSemantic)
// Verify it's valid
assert.True(t, result.Valid)
assert.Empty(t, result.Issues, "Should have no validation issues")
}
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",
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.ValidateServerJSON(serverJSON, validators.ValidationSchemaVersionAndSemantic)
// 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")
}
func TestValidateServerJSON_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",
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.ValidateServerJSON(serverJSON, validators.ValidationAll)
// 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" {
// 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")
}
}
}
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")
}
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 == schemaPath && 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 == schemaPath && 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 == schemaPath && 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 == schemaPath {
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 == schemaPath && (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")
})
}
}