Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ func TestLoadFromStdin_UnsupportedType(t *testing.T) {
// Should fail validation for unsupported type
require.Error(t, err)

// Error should mention validation issue
assert.Contains(t, err.Error(), "validation error", "Expected validation error")
// Error should mention configuration error
assert.Contains(t, err.Error(), "Configuration error", "Expected configuration error")

// Config should be nil on validation error
assert.Nil(t, cfg, "Config should be nil when validation fails")
Expand Down Expand Up @@ -782,11 +782,6 @@ func TestLoadFromStdin_InvalidMountFormat(t *testing.T) {
mounts string
errorMsg string
}{
{
name: "missing mode",
mounts: `["/host:/container"]`,
errorMsg: "validation error",
},
{
name: "invalid mode",
mounts: `["/host:/container:invalid"]`,
Expand Down
23 changes: 14 additions & 9 deletions internal/config/rules/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
// Documentation URL constants
const (
ConfigSpecURL = "https://github.com/githubnext/gh-aw/blob/main/docs/src/content/docs/reference/mcp-gateway.md"
SchemaURL = "https://github.com/githubnext/gh-aw/blob/main/docs/public/schemas/mcp-gateway-config.schema.json"
SchemaURL = "https://raw.githubusercontent.com/githubnext/gh-aw/main/docs/public/schemas/mcp-gateway-config.schema.json"
)

// ValidationError represents a configuration validation error with context
Expand Down Expand Up @@ -104,24 +104,29 @@ func TimeoutPositive(timeout int, fieldName, jsonPath string) *ValidationError {
return nil
}

// MountFormat validates a mount specification in the format "source:dest:mode"
// MountFormat validates a mount specification in the format "source:dest" or "source:dest:mode"
// Returns nil if valid, *ValidationError if invalid
// Per MCP Gateway specification v1.7.0 section 4.1.5:
// - Host path MUST be an absolute path
// - Container path MUST be an absolute path
// - Mode MUST be either "ro" (read-only) or "rw" (read-write)
// - Mode (if provided) MUST be either "ro" (read-only) or "rw" (read-write)
func MountFormat(mount, jsonPath string, index int) *ValidationError {
parts := strings.Split(mount, ":")
if len(parts) != 3 {
if len(parts) < 2 || len(parts) > 3 {
return &ValidationError{
Field: "mounts",
Message: fmt.Sprintf("invalid mount format '%s' (expected 'source:dest:mode')", mount),
Message: fmt.Sprintf("invalid mount format '%s' (expected 'source:dest' or 'source:dest:mode')", mount),
JSONPath: fmt.Sprintf("%s.mounts[%d]", jsonPath, index),
Suggestion: "Use format 'source:dest:mode' where mode is 'ro' (read-only) or 'rw' (read-write)",
Suggestion: "Use format 'source:dest' or 'source:dest:mode' where mode is 'ro' (read-only) or 'rw' (read-write)",
}
}

source, dest, mode := parts[0], parts[1], parts[2]
source := parts[0]
dest := parts[1]
mode := ""
if len(parts) == 3 {
mode = parts[2]
}

// Validate source is not empty
if source == "" {
Expand Down Expand Up @@ -163,8 +168,8 @@ func MountFormat(mount, jsonPath string, index int) *ValidationError {
}
}

// Validate mode
if mode != "ro" && mode != "rw" {
// Validate mode if provided
if mode != "" && mode != "ro" && mode != "rw" {
return &ValidationError{
Field: "mounts",
Message: fmt.Sprintf("invalid mount mode '%s' (must be 'ro' or 'rw')", mode),
Expand Down
5 changes: 2 additions & 3 deletions internal/config/rules/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,11 @@ func TestMountFormat(t *testing.T) {
shouldErr: false,
},
{
name: "invalid format - missing mode",
name: "valid mount without mode",
mount: "/host/path:/container/path",
jsonPath: "mcpServers.github",
index: 0,
shouldErr: true,
errMsg: "invalid mount format",
shouldErr: false,
},
{
name: "invalid format - too many colons",
Expand Down
118 changes: 109 additions & 9 deletions internal/config/schema_validation.go
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
package config

import (
_ "embed"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"

"github.com/githubnext/gh-aw-mcpg/internal/config/rules"
"github.com/santhosh-tekuri/jsonschema/v5"
)

//go:embed schemas/mcp-gateway-config.schema.json
var schemaJSON []byte

var (
// Compile regex patterns from schema for additional validation
containerPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9./_-]*(:([a-zA-Z0-9._-]+|latest))?$`)
urlPattern = regexp.MustCompile(`^https?://.+`)
mountPattern = regexp.MustCompile(`^[^:]+:[^:]+:(ro|rw)$`)
mountPattern = regexp.MustCompile(`^[^:]+:[^:]+(:(ro|rw))?$`)
domainVarPattern = regexp.MustCompile(`^\$\{[A-Z_][A-Z0-9_]*\}$`)

// gatewayVersion stores the version string to include in error messages
Expand All @@ -32,25 +31,126 @@ func SetVersion(version string) {
}
}

// fetchAndFixSchema fetches the JSON schema from the remote URL and fixes
// regex patterns that use negative lookahead (not supported in JSON Schema Draft 7)
func fetchAndFixSchema(url string) ([]byte, error) {
client := &http.Client{
Timeout: 10 * time.Second,
}

resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch schema from %s: %w", url, err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch schema: HTTP %d", resp.StatusCode)
}

schemaBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read schema response: %w", err)
}

// Fix regex patterns that use negative lookahead
var schema map[string]interface{}
if err := json.Unmarshal(schemaBytes, &schema); err != nil {
return nil, fmt.Errorf("failed to parse schema: %w", err)
}

// Fix the customServerConfig pattern that uses negative lookahead
// The oneOf constraint in mcpServerConfig will still ensure that stdio/http
// types are validated correctly. We replace the pattern with an enum that excludes
// stdio and http, which achieves the same validation goal without negative lookahead.
if definitions, ok := schema["definitions"].(map[string]interface{}); ok {
if customServerConfig, ok := definitions["customServerConfig"].(map[string]interface{}); ok {
if properties, ok := customServerConfig["properties"].(map[string]interface{}); ok {
if typeField, ok := properties["type"].(map[string]interface{}); ok {
// Remove the pattern entirely - the oneOf logic combined with the fact
// that stdioServerConfig has enum: ["stdio"] and httpServerConfig has
// enum: ["http"] will ensure proper validation
delete(typeField, "pattern")
// Also remove the type constraint since we want it to only match in the oneOf context
delete(typeField, "type")
// Add a not constraint to exclude stdio and http
typeField["not"] = map[string]interface{}{
"enum": []string{"stdio", "http"},
}
}
}
}
}

// Fix the customSchemas patternProperties
if properties, ok := schema["properties"].(map[string]interface{}); ok {
if customSchemas, ok := properties["customSchemas"].(map[string]interface{}); ok {
if patternProps, ok := customSchemas["patternProperties"].(map[string]interface{}); ok {
// Find and replace the pattern property key with negative lookahead
for key, value := range patternProps {
if strings.Contains(key, "(?!") {
// Replace with a simple pattern that matches any lowercase word
// The validation logic will handle ensuring it's not stdio/http
delete(patternProps, key)
patternProps["^[a-z][a-z0-9-]*$"] = value
break
}
}
}
}
}

fixedBytes, err := json.Marshal(schema)
if err != nil {
return nil, fmt.Errorf("failed to marshal fixed schema: %w", err)
}

return fixedBytes, nil
}

// validateJSONSchema validates the raw JSON configuration against the JSON schema
func validateJSONSchema(data []byte) error {
// Fetch the schema from the remote URL (source of truth)
schemaURL := "https://raw.githubusercontent.com/githubnext/gh-aw/main/docs/public/schemas/mcp-gateway-config.schema.json"
schemaJSON, err := fetchAndFixSchema(schemaURL)
if err != nil {
return fmt.Errorf("failed to fetch schema: %w", err)
}

// Parse the schema
var schemaData interface{}
if err := json.Unmarshal(schemaJSON, &schemaData); err != nil {
return fmt.Errorf("failed to parse embedded schema: %w", err)
return fmt.Errorf("failed to parse schema: %w", err)
}

// Compile the schema
compiler := jsonschema.NewCompiler()
compiler.Draft = jsonschema.Draft7

// Add the schema with its $id
schemaURL := "https://github.com/githubnext/gh-aw/blob/main/docs/public/schemas/mcp-gateway-config.schema.json"
// Add the schema with its $id from the remote schema
// Note: The remote schema uses https://docs.github.com/gh-aw/schemas/mcp-gateway-config.schema.json
// as its $id, so we need to register it there as well
var schemaObj map[string]interface{}
if err := json.Unmarshal(schemaJSON, &schemaObj); err != nil {
return fmt.Errorf("failed to parse schema JSON: %w", err)
}

schemaID, ok := schemaObj["$id"].(string)
if !ok || schemaID == "" {
schemaID = schemaURL
}

// Add the schema with both URLs
if err := compiler.AddResource(schemaURL, strings.NewReader(string(schemaJSON))); err != nil {
return fmt.Errorf("failed to add schema resource: %w", err)
}
if schemaID != schemaURL {
if err := compiler.AddResource(schemaID, strings.NewReader(string(schemaJSON))); err != nil {
return fmt.Errorf("failed to add schema resource with $id: %w", err)
}
}

schema, err := compiler.Compile(schemaURL)
schema, err := compiler.Compile(schemaID)
if err != nil {
return fmt.Errorf("failed to compile schema: %w", err)
}
Expand Down
7 changes: 3 additions & 4 deletions internal/config/schema_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ func TestValidateStringPatterns(t *testing.T) {
shouldErr: false,
},
{
name: "invalid mount pattern - missing mode",
name: "valid mount without mode",
config: &StdinConfig{
MCPServers: map[string]*StdinServerConfig{
"test": {
Expand All @@ -401,8 +401,7 @@ func TestValidateStringPatterns(t *testing.T) {
},
},
},
shouldErr: true,
errorMsg: "does not match required pattern",
shouldErr: false,
},
{
name: "valid http url pattern",
Expand Down Expand Up @@ -556,7 +555,7 @@ func TestEnhancedErrorMessages(t *testing.T) {
"Location:",
"Error:",
"Details:",
"https://github.com/githubnext/gh-aw/blob/main/docs/public/schemas/mcp-gateway-config.schema.json",
"https://raw.githubusercontent.com/githubnext/gh-aw/main/docs/public/schemas/mcp-gateway-config.schema.json",
},
},
{
Expand Down
Loading