Skip to content

Implement zero-config quick mode for thv vmcp serve #4886

Description

@yrobla

Description

Add zero-config quick-mode logic to pkg/vmcp/cli/serve.go: when no --config flag is provided but --group is specified, auto-generate a minimal in-memory *config.Config struct that sets groupRef from --group and binds the listener to 127.0.0.1 only. This eliminates the need for a config file for the common case of aggregating a ToolHive group, making thv vmcp serve --group <name> fully self-contained. Add unit tests covering the required groupRef field and the localhost binding security constraint.

Context

#4879 created pkg/vmcp/cli/serve.go with Serve(ctx, ServeConfig) and a ServeConfig struct. #4883 created cmd/thv/app/vmcp.go with the thv vmcp serve Cobra command, wired with a --config flag that is currently marked required. This item relaxes that requirement: when --config is absent, the serve logic in pkg/vmcp/cli/serve.go generates a minimal in-memory config from --group rather than returning an error. The --group flag is already present on the serve subcommand (established in #4883) and must be populated for quick mode to proceed.

RFC THV-0059 specifies this as Phase 3 quick mode: the auto-generated config uses dynamic backend discovery (groups manager), sets outgoingAuth.source: "inline" to avoid K8s API access, binds to 127.0.0.1 for security, and leaves all optional fields at their zero values so the full config validation path still runs. The generated config is purely in-memory — no YAML file is written to disk.

Dependencies: #4883
Blocks: #4887, #4888

Acceptance Criteria

  • ServeConfig in pkg/vmcp/cli/serve.go gains a GroupRef string field (the --group flag value) alongside the existing ConfigPath string field
  • Serve() returns a descriptive error when both ConfigPath and GroupRef are empty (neither --config nor --group was given)
  • Serve() returns a descriptive error when GroupRef is empty and ConfigPath is also empty, with a message guiding the user to pass either --config or --group
  • When ConfigPath is empty and GroupRef is non-empty, Serve() generates a minimal in-memory *config.Config with Group (i.e. groupRef) set to the GroupRef value and OutgoingAuth.Source set to "inline"
  • The auto-generated config does not set a Host or Port override (those are controlled by ServeConfig.Host and ServeConfig.Port as before); the listener address remains 127.0.0.1 as the default for ServeConfig.Host
  • The auto-generated config passes config.NewValidator().Validate() without error
  • The --config flag on thv vmcp serve is no longer marked as required in cmd/thv/app/vmcp.go; omitting it while providing --group is valid
  • thv vmcp serve --group <name> starts successfully (smoke-level: no flag-parsing or config-generation error before the server attempts to bind)
  • thv vmcp serve with neither --config nor --group exits non-zero with a descriptive error
  • Unit tests in pkg/vmcp/cli/serve_test.go (or a dedicated quick_mode_test.go) cover: (a) quick-mode config generation sets groupRef correctly, (b) quick-mode config generation sets outgoingAuth.source to "inline", (c) the generated config passes NewValidator().Validate(), (d) both flags absent returns an error
  • All existing tests pass (no regressions)
  • Code reviewed and approved

Technical Approach

Recommended Implementation

The change is confined to pkg/vmcp/cli/serve.go (business logic) and the small flag-marking change in cmd/thv/app/vmcp.go (remove _ = cmd.MarkFlagRequired("config")).

In pkg/vmcp/cli/serve.go:

  1. Add GroupRef string to ServeConfig.
  2. At the top of Serve(), after reading cfg.ConfigPath and cfg.GroupRef, implement a branching config-load path:
    • If ConfigPath != "": use the existing loadAndValidateConfig(cfg.ConfigPath) path (unchanged).
    • If GroupRef != "" (and ConfigPath == ""): call a new unexported generateQuickModeConfig(groupRef string) (*config.Config, error) helper.
    • If both are empty: return fmt.Errorf("either --config or --group must be specified").
  3. Implement generateQuickModeConfig:
    • Construct a *config.Config in-memory with Group: groupRef and OutgoingAuth: &config.OutgoingAuthConfig{Source: "inline"}.
    • Run config.NewValidator().Validate(cfg) before returning — fail fast if the minimal config is structurally invalid.
    • Return the validated config.

In cmd/thv/app/vmcp.go:

  • Remove _ = cmd.MarkFlagRequired("config") from newVMCPServeCommand().
  • Add cmd.Flags().StringVar(&group, "group", "", "ToolHive group name (used for zero-config quick mode when --config is omitted)").
  • Pass the group flag value as GroupRef in the vmcpcli.ServeConfig struct literal.

Patterns & Frameworks

  • Thin wrapper principle: All branching logic (--config vs --group) lives in pkg/vmcp/cli/serve.go, not in cmd/thv/app/vmcp.go. The Cobra command only passes flag values through.
  • SPDX headers: New files must open with // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. and // SPDX-License-Identifier: Apache-2.0.
  • Immutable variable assignment: Use immediately-assigned variables rather than var cfg *config.Config reassigned across branches; use an immediately-invoked anonymous function or a helper function to keep cfg single-assignment.
  • Error wrapping: fmt.Errorf("...: %w", err) for all wrapped errors; descriptive messages that name the missing flag.
  • Strict YAML parsing: The generated config is never serialized to YAML; it goes directly into the existing serve pipeline, so no marshaling/unmarshaling is needed.
  • No new external Go module dependencies: the config package is already imported in serve.go; no new imports.
  • Unit test style: Table-driven tests using testify/require assertions alongside source in pkg/vmcp/cli/; use config.NewValidator() directly to assert the generated config passes validation.

Code Pointers

  • pkg/vmcp/cli/serve.go (created by Extract shared vMCP logic into pkg/vmcp/cli/ (serve + validate) #4879) — Primary file to modify; add GroupRef to ServeConfig, add generateQuickModeConfig, branch at top of Serve()
  • pkg/vmcp/config/config.goConfig struct (the Group field has YAML tag groupRef; OutgoingAuthConfig.Source must be "inline" for static/local mode); NewValidator() / Validator.Validate() for pre-flight check of generated config
  • cmd/thv/app/vmcp.go (created by Add thv vmcp serve and thv vmcp validate subcommands #4883) — Remove MarkFlagRequired("config"), add --group flag, pass GroupRef in ServeConfig
  • cmd/vmcp/app/commands.go — Reference for how the existing serve path calls loadAndValidateConfig; shows what fields Config must minimally contain for the serve pipeline to proceed
  • pkg/vmcp/cli/serve_test.go (or new quick_mode_test.go) — Unit test file to create; follow table-driven test patterns from test/integration/vmcp/helpers/vmcp_server.go and .claude/rules/testing.md
  • .claude/rules/go-style.md — Immutable variable assignment guidance, error handling, SPDX headers
  • .claude/rules/testing.md — Unit test style, testify/require, table-driven tests for pkg/ packages

Component Interfaces

// pkg/vmcp/cli/serve.go

// ServeConfig holds all parameters for starting the vMCP server.
// Exactly one of ConfigPath or GroupRef must be non-empty.
type ServeConfig struct {
    ConfigPath  string // path to YAML config file; takes precedence over GroupRef
    GroupRef    string // ToolHive group name; used when ConfigPath is empty (quick mode)
    Host        string
    Port        int
    EnableAudit bool
}

// generateQuickModeConfig constructs a minimal in-memory config for quick mode.
// It sets groupRef from groupRef and outgoingAuth.source to "inline", then
// validates the result. Returns an error if groupRef is empty or if validation fails.
func generateQuickModeConfig(groupRef string) (*config.Config, error) {
    cfg := &config.Config{
        Group: groupRef,
        OutgoingAuth: &config.OutgoingAuthConfig{
            Source: "inline",
        },
    }
    validator := config.NewValidator()
    if err := validator.Validate(cfg); err != nil {
        return nil, fmt.Errorf("quick-mode config validation failed: %w", err)
    }
    return cfg, nil
}
// pkg/vmcp/cli/serve_test.go (or quick_mode_test.go)

func TestGenerateQuickModeConfig(t *testing.T) {
    t.Parallel()
    tests := []struct{
        name     string
        groupRef string
        wantErr  bool
    }{
        {name: "valid group", groupRef: "default", wantErr: false},
        {name: "empty group returns error", groupRef: "", wantErr: true},
    }
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            cfg, err := generateQuickModeConfig(tc.groupRef)
            if tc.wantErr {
                require.Error(t, err)
                return
            }
            require.NoError(t, err)
            require.Equal(t, tc.groupRef, cfg.Group)
            require.NotNil(t, cfg.OutgoingAuth)
            require.Equal(t, "inline", cfg.OutgoingAuth.Source)
            // Verify the generated config passes the real validator
            require.NoError(t, config.NewValidator().Validate(cfg))
        })
    }
}

Testing Strategy

Unit Tests

  • generateQuickModeConfig("default") returns a config with Group == "default" and OutgoingAuth.Source == "inline"
  • generateQuickModeConfig("") returns a non-nil error (empty groupRef is not valid)
  • The generated config (non-empty groupRef) passes config.NewValidator().Validate() without error
  • Serve() with both ConfigPath == "" and GroupRef == "" returns an error containing guidance to pass --config or --group
  • Serve() with GroupRef == "mygroup" and ConfigPath == "" calls generateQuickModeConfig path (verify by checking no file-not-found error before the bind step; can use a context that cancels immediately to limit execution)

Integration Tests

Edge Cases

  • When both ConfigPath and GroupRef are set, ConfigPath takes precedence (file-based path is preferred and the GroupRef is ignored)
  • A GroupRef containing special characters (spaces, slashes) is propagated as-is to cfg.Group — validation is delegated to config.NewValidator()

Out of Scope

References

  • RFC THV-0059 — Phase 3: zero-config quick mode; specifies 127.0.0.1 binding, groupRef population, and outgoingAuth.source: "inline" for static discovery
  • GitHub Issue #4808 — Parent tracking issue
  • pkg/vmcp/config/config.goConfig.Group field (groupRef YAML key), OutgoingAuthConfig.Source, NewValidator()
  • cmd/vmcp/app/commands.go — Existing runServe and loadAndValidateConfig logic that Serve() delegates to; shows required config fields for the serve pipeline
  • cmd/thv/app/vmcp.go (Add thv vmcp serve and thv vmcp validate subcommands #4883) — Thin wrapper where --config flag marking must be relaxed and --group flag added
  • .claude/rules/cli-commands.md — Thin wrapper principle; new flag checklist
  • .claude/rules/go-style.md — SPDX headers, immutable variable assignment, error wrapping
  • .claude/rules/testing.md — Unit test strategy for pkg/ packages; table-driven tests with testify/require

Metadata

Metadata

Assignees

No one assigned

    Labels

    cliChanges that impact CLI functionalityenhancementNew feature or requestvmcpVirtual MCP Server related issues

    Fields

    No fields configured for Task 📋.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions