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
106 changes: 2 additions & 104 deletions pkg/gateway/activateprofile.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"slices"
"strings"

"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/docker/mcp-gateway/pkg/catalog"
Expand Down Expand Up @@ -143,81 +142,7 @@ func (g *Gateway) ActivateProfile(ctx context.Context, ws workingset.WorkingSet)

// Check if all required config values are set and validate against schema
if len(serverConfig.Config) > 0 {
// Get config from profile
serverConfigMap := profileConfig.config[serverName]

for _, configItem := range serverConfig.Config {
// Config items are object schemas with a "properties" map.
// The "name" field is just an identifier, not a key in serverConfigMap.
schemaMap, ok := configItem.(map[string]any)
if !ok {
continue
}

properties, ok := schemaMap["properties"].(map[string]any)
if !ok {
continue
}

// Build a set of required property names
requiredProps := make(map[string]bool)
if requiredList, ok := schemaMap["required"].([]any); ok {
for _, r := range requiredList {
if s, ok := r.(string); ok {
requiredProps[s] = true
}
}
}

// Validate each property individually
for propName, propSchema := range properties {
propSchemaMap, ok := propSchema.(map[string]any)
if !ok {
continue
}

// Get the value from the user-provided config
configValue, exists := serverConfigMap[propName]
if !exists {
// If the property has a default, the server will use it
if _, hasDefault := propSchemaMap["default"]; hasDefault {
continue
}
// Only flag as missing if explicitly required
if requiredProps[propName] {
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (missing)", propName))
}
continue
}

// Validate the value against the property schema
schemaBytes, err := json.Marshal(propSchemaMap)
if err != nil {
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (invalid schema)", propName))
continue
}

var propSchemaObj jsonschema.Schema
if err := json.Unmarshal(schemaBytes, &propSchemaObj); err != nil {
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (invalid schema)", propName))
continue
}

resolved, err := propSchemaObj.Resolve(nil)
if err != nil {
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (schema resolution failed)", propName))
continue
}

if err := resolved.Validate(configValue); err != nil {
errMsg := err.Error()
if len(errMsg) > 100 {
errMsg = errMsg[:97] + "..."
}
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (%s)", propName, errMsg))
}
}
}
validation.missingConfig = append(validation.missingConfig, validateServerConfig(serverConfig, profileConfig.config[serverName])...)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ActivateProfile is the other caller of the shared validateServerConfig, and this refactor moved its inline validation + error formatting out into the new helpers (validateServerConfig + formatProfileValidationError). But there's no test exercising this path — only the startup/readOnce path is covered (TestWorkingSetConfigurationReadRejectsMissingRequiredConfig); a grep for ActivateProfile across *_test.go finds nothing.

Worth adding a test that calls ActivateProfile with a server missing a required config value (and ideally also a missing secret) and asserts the aggregated message — Cannot activate profile '…' / Server '…' / Missing/invalid config: … (missing) — so the wiring (does the handler still collect missingConfig and aggregate it with missingSecrets/imagePullError?) is locked, not just the helper in isolation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 60153709. Added TestActivateProfileRejectsMissingRequiredConfig, which exercises ActivateProfile with a remote server snapshot, missing required config, and a missing secret against an empty secrets-engine response. It asserts the aggregated Cannot activate profile, Server, Missing secrets, and Missing/invalid config message pieces.

}

// Validate that Docker image can be pulled
Expand All @@ -236,26 +161,7 @@ func (g *Gateway) ActivateProfile(ctx context.Context, ws workingset.WorkingSet)

// If any validation errors, return detailed error message
if len(validationErrors) > 0 {
var errorMessages []string
errorMessages = append(errorMessages, fmt.Sprintf("Cannot activate profile '%s'. Validation failed for %d server(s):", ws.Name, len(validationErrors)))

for _, validation := range validationErrors {
errorMessages = append(errorMessages, fmt.Sprintf("\nServer '%s':", validation.serverName))

if len(validation.missingSecrets) > 0 {
errorMessages = append(errorMessages, fmt.Sprintf(" Missing secrets: %s", strings.Join(validation.missingSecrets, ", ")))
}

if len(validation.missingConfig) > 0 {
errorMessages = append(errorMessages, fmt.Sprintf(" Missing/invalid config: %s", strings.Join(validation.missingConfig, ", ")))
}

if validation.imagePullError != nil {
errorMessages = append(errorMessages, fmt.Sprintf(" Image pull failed: %v", validation.imagePullError))
}
}

return fmt.Errorf("%s", strings.Join(errorMessages, "\n"))
return formatProfileValidationError(ws.Name, validationErrors)
}

// All validations passed - merge configuration into current gateway
Expand Down Expand Up @@ -336,14 +242,6 @@ func (g *Gateway) ActivateProfile(ctx context.Context, ws workingset.WorkingSet)
return nil
}

// serverValidation holds validation results for a single server
type serverValidation struct {
serverName string
missingSecrets []string
missingConfig []string
imagePullError error
}

func activateProfileHandler(g *Gateway, _ *clientConfig) mcp.ToolHandler {
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Parse profile-id parameter
Expand Down
163 changes: 163 additions & 0 deletions pkg/gateway/config_validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package gateway

import (
"encoding/json"
"fmt"
"strings"

"github.com/google/jsonschema-go/jsonschema"

"github.com/docker/mcp-gateway/pkg/catalog"
"github.com/docker/mcp-gateway/pkg/workingset"
)

// serverValidation holds validation results for a single server.
type serverValidation struct {
serverName string
missingSecrets []string
missingConfig []string
imagePullError error
}

func validateWorkingSetServerConfigs(ws workingset.WorkingSet) []serverValidation {
var validationErrors []serverValidation

for _, server := range ws.Servers {
if server.Snapshot == nil {
continue
}

serverName := server.Snapshot.Server.Name
missingConfig := validateServerConfig(server.Snapshot.Server, server.Config)
if len(missingConfig) == 0 {
continue
}

validationErrors = append(validationErrors, serverValidation{
serverName: serverName,
missingConfig: missingConfig,
})
}

return validationErrors
}

func validateServerConfig(server catalog.Server, serverConfigMap map[string]any) []string {
var missingConfig []string

for _, configItem := range server.Config {
schemaMap, ok := configItem.(map[string]any)
if !ok {
continue
}

properties, ok := schemaMap["properties"].(map[string]any)
if !ok {
continue
}

requiredProps := requiredConfigProperties(schemaMap)

for propName, propSchema := range properties {
propSchemaMap, ok := propSchema.(map[string]any)
if !ok {
continue
}

configValue, exists := serverConfigMap[propName]
if !exists {
if _, hasDefault := propSchemaMap["default"]; hasDefault {
continue
}
if requiredProps[propName] {
missingConfig = append(missingConfig, fmt.Sprintf("%s (missing)", propName))
}
continue
}
if isEmptyConfigValue(configValue) {
if requiredProps[propName] {
missingConfig = append(missingConfig, fmt.Sprintf("%s (missing)", propName))
}
continue
Comment on lines +77 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow empty object config to validate

For a profile with a supported object-valued config property, an empty object is still a present value and may satisfy the JSON schema unless that schema adds nested required or minProperties. This branch treats every empty map[string]any as missing and returns before resolved.Validate can decide, so gateway run --profile and profile activation now reject valid configs such as a required headers: {} object.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e8bbf861. isEmptyConfigValue now only treats nil and empty strings as missing, so an empty object like headers: {} is passed through to JSON Schema validation. I also added TestValidateServerConfigAllowsEmptyObjectValue to cover the required empty-object case.

}
Comment on lines +77 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isEmptyConfigValue returns before resolved.Validate, so the schema-validation branch below (the resolved.Validate(configValue) block) is the only thing keeping the empty-{} relaxation narrow: a required string-typed field set to {} or [] is caught only there, not by isEmptyConfigValue. That backstop currently has no test — a one-line future change broadening isEmptyConfigValue to also treat empty maps/slices as "skip" would silently drop the type-mismatch check with nothing failing.

A negative test would pin it: validateServerConfig should reject config_path: {} (and config_path: []) for a string-typed required field. (TestValidateServerConfigAllowsEmptyObjectValue covers the allow side; this covers the reject side that makes the allow safe.)

Minor, separate: an empty optional string now skips minLength/pattern validation it previously hit. Almost certainly fine — the registry import schema generator doesn't emit those constraints — just flagging the behavior change for hand-authored catalog/profile schemas.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 60153709. Added TestValidateServerConfigRejectsWrongTypeForRequiredString covering config_path: {} and config_path: [] against a required string schema, so those values are rejected by JSON Schema validation rather than treated as missing or skipped.


schemaBytes, err := json.Marshal(propSchemaMap)
if err != nil {
missingConfig = append(missingConfig, fmt.Sprintf("%s (invalid schema)", propName))
continue
}

var propSchemaObj jsonschema.Schema
if err := json.Unmarshal(schemaBytes, &propSchemaObj); err != nil {
missingConfig = append(missingConfig, fmt.Sprintf("%s (invalid schema)", propName))
continue
}

resolved, err := propSchemaObj.Resolve(nil)
if err != nil {
missingConfig = append(missingConfig, fmt.Sprintf("%s (schema resolution failed)", propName))
continue
}

if err := resolved.Validate(configValue); err != nil {
errMsg := err.Error()
if len(errMsg) > 100 {
errMsg = errMsg[:97] + "..."
}
missingConfig = append(missingConfig, fmt.Sprintf("%s (%s)", propName, errMsg))
}
}
}

return missingConfig
}

func requiredConfigProperties(schemaMap map[string]any) map[string]bool {
requiredProps := make(map[string]bool)
switch requiredList := schemaMap["required"].(type) {
case []any:
for _, r := range requiredList {
if s, ok := r.(string); ok {
requiredProps[s] = true
}
}
case []string:
for _, s := range requiredList {
requiredProps[s] = true
}
}
return requiredProps
}

func isEmptyConfigValue(v any) bool {
if v == nil {
return true
}
if s, ok := v.(string); ok {
return s == ""
}
return false
}

func formatProfileValidationError(profileName string, validationErrors []serverValidation) error {
var errorMessages []string
errorMessages = append(errorMessages, fmt.Sprintf("Cannot activate profile '%s'. Validation failed for %d server(s):", profileName, len(validationErrors)))

for _, validation := range validationErrors {
errorMessages = append(errorMessages, fmt.Sprintf("\nServer '%s':", validation.serverName))

if len(validation.missingSecrets) > 0 {
errorMessages = append(errorMessages, fmt.Sprintf(" Missing secrets: %s", strings.Join(validation.missingSecrets, ", ")))
}

if len(validation.missingConfig) > 0 {
errorMessages = append(errorMessages, fmt.Sprintf(" Missing/invalid config: %s", strings.Join(validation.missingConfig, ", ")))
}

if validation.imagePullError != nil {
errorMessages = append(errorMessages, fmt.Sprintf(" Image pull failed: %v", validation.imagePullError))
}
}

return fmt.Errorf("%s", strings.Join(errorMessages, "\n"))
}
Loading
Loading