Skip to content
Open
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
5 changes: 5 additions & 0 deletions cmd/thv/app/vmcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ If neither --output nor --config is provided, the generated YAML is written to s
// newVMCPValidateCommand returns the "vmcp validate" subcommand.
func newVMCPValidateCommand() *cobra.Command {
var configPath string
var format string
cmd := &cobra.Command{
Use: "validate",
Short: "Validate a vMCP configuration file",
Expand All @@ -141,10 +142,14 @@ for valid configurations, non-zero with a descriptive error otherwise.`,
RunE: func(cmd *cobra.Command, _ []string) error {
return vmcpcli.Validate(cmd.Context(), vmcpcli.ValidateConfig{
ConfigPath: configPath,
Format: format,
Writer: cmd.OutOrStdout(),
})
},
}
cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to vMCP configuration file (required)")
AddFormatFlag(cmd, &format)
cmd.PreRunE = ValidateFormat(&format)
_ = cmd.MarkFlagRequired("config")
return cmd
}
49 changes: 49 additions & 0 deletions cmd/thv/app/vmcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
package app

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -56,3 +60,48 @@ func TestNewVMCPCommand_InitRegistered(t *testing.T) {
}
assert.True(t, found, "expected 'init' to be registered as a subcommand of 'vmcp'")
}

func TestNewVMCPValidateCommand_FormatFlag(t *testing.T) {
t.Parallel()

cmd := newVMCPValidateCommand()
formatFlag := cmd.Flags().Lookup("format")
require.NotNil(t, formatFlag, "expected --format flag to be registered")
assert.Equal(t, FormatText, formatFlag.DefValue)

require.NoError(t, formatFlag.Value.Set("yaml"))
err := cmd.PreRunE(cmd, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), `invalid format "yaml"`)
}

func TestNewVMCPValidateCommand_JSONOutput(t *testing.T) {
t.Parallel()

configPath := filepath.Join(t.TempDir(), "vmcp.yaml")
require.NoError(t, os.WriteFile(configPath, []byte(`
name: cli-json-vmcp
groupRef: cli-json-group
incomingAuth:
type: anonymous
outgoingAuth:
source: inline
default:
type: unauthenticated
aggregation:
conflictResolution: prefix
conflictResolutionConfig:
prefixFormat: "{workload}_"
`), 0o600))

cmd := newVMCPValidateCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetArgs([]string{"--config", configPath, "--format", FormatJSON})
require.NoError(t, cmd.Execute())

var summary map[string]any
require.NoError(t, json.Unmarshal(output.Bytes(), &summary))
assert.Equal(t, true, summary["valid"])
assert.Equal(t, "cli-json-vmcp", summary["name"])
}
1 change: 1 addition & 0 deletions docs/cli/thv_vmcp_validate.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions pkg/vmcp/cli/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ package cli

import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"

"github.com/stacklok/toolhive-core/env"
"github.com/stacklok/toolhive/pkg/vmcp/config"
Expand All @@ -16,6 +19,23 @@ import (
type ValidateConfig struct {
// ConfigPath is the path to the vMCP YAML configuration file to validate.
ConfigPath string
// Format controls successful output. Empty or "text" preserves the log summary.
Format string
// Writer receives JSON output. Nil uses os.Stdout.
Writer io.Writer
}

// ValidationSummary is the stable, secret-free machine-readable validation result.
type ValidationSummary struct {
Valid bool `json:"valid"`
Name string `json:"name"`
Group string `json:"group"`
IncomingAuth string `json:"incoming_auth"`
OutgoingAuthSource string `json:"outgoing_auth_source"`
BackendAuthOverrides int `json:"backend_auth_override_count"`
BackendCount int `json:"backend_count"`
ConflictResolution string `json:"conflict_resolution"`
CompositeToolCount int `json:"composite_tool_count"`
}

// Validate loads and validates a vMCP configuration file, printing a summary
Expand All @@ -25,6 +45,9 @@ func Validate(_ context.Context, cfg ValidateConfig) error {
if cfg.ConfigPath == "" {
return fmt.Errorf("no configuration file specified, use --config flag")
}
if cfg.Format != "" && cfg.Format != "text" && cfg.Format != "json" {
return fmt.Errorf("unsupported output format %q", cfg.Format)
}

slog.Info(fmt.Sprintf("Validating configuration: %s", cfg.ConfigPath))

Expand All @@ -44,6 +67,30 @@ func Validate(_ context.Context, cfg ValidateConfig) error {
return fmt.Errorf("validation failed: %w", err)
}

summary := ValidationSummary{
Valid: true,
Name: vmcpCfg.Name,
Group: vmcpCfg.Group,
IncomingAuth: vmcpCfg.IncomingAuth.Type,
OutgoingAuthSource: vmcpCfg.OutgoingAuth.Source,
BackendAuthOverrides: len(vmcpCfg.OutgoingAuth.Backends),
BackendCount: len(vmcpCfg.Backends),
ConflictResolution: string(vmcpCfg.Aggregation.ConflictResolution),
CompositeToolCount: len(vmcpCfg.CompositeTools),
}
if cfg.Format == "json" {
writer := cfg.Writer
if writer == nil {
writer = os.Stdout
}
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")
if err := encoder.Encode(summary); err != nil {
return fmt.Errorf("failed to encode validation summary: %w", err)
}
return nil
}

slog.Info("✓ Configuration is valid")
slog.Info(fmt.Sprintf(" Name: %s", vmcpCfg.Name))
slog.Info(fmt.Sprintf(" Group: %s", vmcpCfg.Group))
Expand Down
115 changes: 115 additions & 0 deletions pkg/vmcp/cli/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
package cli

import (
"bytes"
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -115,3 +119,114 @@ aggregation:
})
}
}

func TestValidateJSONSummaryIsStableAndSecretFree(t *testing.T) {
t.Parallel()

path := filepath.Join(t.TempDir(), "vmcp.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
name: sensitive-vmcp
groupRef: sensitive-group

backends:
- name: first-backend
url: http://first.example.test/mcp
transport: streamable-http
- name: second-backend
url: http://second.example.test/sse
transport: sse

incomingAuth:
type: anonymous

outgoingAuth:
source: inline
default:
type: unauthenticated
backends:
protected-backend:
type: header_injection
headerInjection:
headerName: X-Private-API-Key
headerValue: vmcp-json-secret-value

aggregation:
conflictResolution: prefix
conflictResolutionConfig:
prefixFormat: "{workload}_"

telemetry:
headers:
X-Telemetry-Token: vmcp-telemetry-secret-value

passthroughHeaders:
- X-Tenant-Token
`), 0o600))

var output bytes.Buffer
err := Validate(context.Background(), ValidateConfig{
ConfigPath: path,
Format: "json",
Writer: &output,
})
require.NoError(t, err)

var got map[string]any
require.NoError(t, json.Unmarshal(output.Bytes(), &got))
assert.Equal(t, map[string]any{
"valid": true,
"name": "sensitive-vmcp",
"group": "sensitive-group",
"incoming_auth": "anonymous",
"outgoing_auth_source": "inline",
"backend_auth_override_count": float64(1),
"backend_count": float64(2),
"conflict_resolution": "prefix",
"composite_tool_count": float64(0),
}, got)

for _, sensitiveValue := range []string{
"vmcp-json-secret-value",
"vmcp-telemetry-secret-value",
"X-Private-API-Key",
"X-Telemetry-Token",
"X-Tenant-Token",
"first.example.test",
"second.example.test",
} {
assert.NotContains(t, output.String(), sensitiveValue)
}
}

//nolint:paralleltest // slog.SetDefault is process-wide and must be restored before parallel tests run.
func TestValidateTextOutputRemainsCompatible(t *testing.T) {
path := filepath.Join(t.TempDir(), "vmcp.yaml")
require.NoError(t, os.WriteFile(path, []byte(validConfigYAML), 0o600))

var logs bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
t.Cleanup(func() { slog.SetDefault(previousLogger) })

for _, format := range []string{"", "text"} {
logs.Reset()
var output bytes.Buffer
require.NoError(t, Validate(context.Background(), ValidateConfig{
ConfigPath: path,
Format: format,
Writer: &output,
}))

assert.Empty(t, output.String(), "text output must not be written to the JSON writer")
for _, summaryLine := range []string{
"Configuration is valid",
"Name: test-vmcp",
"Group: test-group",
"Incoming Auth: anonymous",
"Outgoing Auth: default only (source: inline)",
"Conflict Resolution: prefix",
} {
assert.Contains(t, logs.String(), summaryLine)
}
}
}