Skip to content

Commit e4bfbfe

Browse files
yroblataskbot
andauthored
Add thv vmcp serve and thv vmcp validate subcommands (#4900)
Create cmd/thv/app/vmcp.go with newVMCPCommand(), newVMCPServeCommand(), and newVMCPValidateCommand() — thin wrappers that parse flags and delegate to pkg/vmcp/cli/Serve and pkg/vmcp/cli/Validate respectively. Register the command in NewRootCmd() and add "vmcp" to the informationalCommands map so it bypasses container runtime preflight. Regenerate CLI docs via task docs. This is Phase 2 of RFC THV-0059, making thv vmcp serve and thv vmcp validate available to users for the first time. Closes #4883 Co-authored-by: taskbot <taskbot@users.noreply.github.com>
1 parent ee988f3 commit e4bfbfe

11 files changed

Lines changed: 279 additions & 16 deletions

File tree

cmd/thv/app/commands.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ func NewRootCmd(enableUpdates bool) *cobra.Command {
7070
rootCmd.AddCommand(newSecretCommand())
7171
rootCmd.AddCommand(inspectorCommand())
7272
rootCmd.AddCommand(newMCPCommand())
73+
rootCmd.AddCommand(newVMCPCommand())
7374
rootCmd.AddCommand(groupCmd)
7475
rootCmd.AddCommand(skillCmd)
7576
rootCmd.AddCommand(statusCmd)
@@ -100,14 +101,18 @@ func IsInformationalCommand(args []string) bool {
100101

101102
command := args[1]
102103

103-
// Commands that are entirely informational and don't need container runtime
104+
// Commands that don't need container runtime or startup migrations.
105+
// "vmcp" is safe here: telemetry/secret-scope migrations only affect thv run state,
106+
// and EnsureDefaultGroupExists is called inside pkg/vmcp/cli/Serve when dynamic
107+
// backend discovery is used (i.e. when no static backends are configured).
104108
informationalCommands := map[string]bool{
105109
"version": true,
106110
"search": true,
107111
"completion": true,
108112
"registry": true,
109113
"mcp": true,
110114
"skill": true,
115+
"vmcp": true,
111116
}
112117

113118
return informationalCommands[command]

cmd/thv/app/vmcp.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package app
5+
6+
import (
7+
"github.com/spf13/cobra"
8+
9+
vmcpcli "github.com/stacklok/toolhive/pkg/vmcp/cli"
10+
)
11+
12+
// newVMCPCommand returns the top-level "vmcp" Cobra command with subcommands attached.
13+
func newVMCPCommand() *cobra.Command {
14+
cmd := &cobra.Command{
15+
Use: "vmcp",
16+
Short: "Run and manage a Virtual MCP Server locally",
17+
Long: `The vmcp command provides subcommands to run and validate a Virtual MCP
18+
Server (vMCP) locally without Kubernetes. A vMCP aggregates multiple MCP
19+
servers from a ToolHive group into a single unified endpoint.`,
20+
}
21+
cmd.AddCommand(newVMCPServeCommand())
22+
cmd.AddCommand(newVMCPValidateCommand())
23+
return cmd
24+
}
25+
26+
// newVMCPServeCommand returns the "vmcp serve" subcommand.
27+
func newVMCPServeCommand() *cobra.Command {
28+
var (
29+
configPath string
30+
host string
31+
port int
32+
enableAudit bool
33+
)
34+
cmd := &cobra.Command{
35+
Use: "serve",
36+
Short: "Start the Virtual MCP Server",
37+
Long: `Start the Virtual MCP Server to aggregate and proxy multiple MCP servers.
38+
39+
The server reads the configuration file specified by --config and starts
40+
listening for MCP client connections, aggregating tools, resources, and
41+
prompts from all configured backend MCP servers.`,
42+
Args: cobra.NoArgs,
43+
RunE: func(cmd *cobra.Command, _ []string) error {
44+
return vmcpcli.Serve(cmd.Context(), vmcpcli.ServeConfig{
45+
ConfigPath: configPath,
46+
Host: host,
47+
Port: port,
48+
EnableAudit: enableAudit,
49+
})
50+
},
51+
}
52+
cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to vMCP configuration file (required)")
53+
cmd.Flags().StringVar(&host, "host", "127.0.0.1", "Host address to bind to")
54+
cmd.Flags().IntVar(&port, "port", 4483, "Port to listen on")
55+
cmd.Flags().BoolVar(&enableAudit, "enable-audit", false, "Enable audit logging with default configuration")
56+
_ = cmd.MarkFlagRequired("config")
57+
return cmd
58+
}
59+
60+
// newVMCPValidateCommand returns the "vmcp validate" subcommand.
61+
func newVMCPValidateCommand() *cobra.Command {
62+
var configPath string
63+
cmd := &cobra.Command{
64+
Use: "validate",
65+
Short: "Validate a vMCP configuration file",
66+
Long: `Validate the vMCP configuration file for syntax and semantic errors.
67+
68+
This command checks YAML syntax, required field presence, middleware
69+
configuration correctness, and backend configuration validity. Exits 0
70+
for valid configurations, non-zero with a descriptive error otherwise.`,
71+
Args: cobra.NoArgs,
72+
RunE: func(cmd *cobra.Command, _ []string) error {
73+
return vmcpcli.Validate(cmd.Context(), vmcpcli.ValidateConfig{
74+
ConfigPath: configPath,
75+
})
76+
},
77+
}
78+
cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to vMCP configuration file (required)")
79+
_ = cmd.MarkFlagRequired("config")
80+
return cmd
81+
}

cmd/thv/main.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,13 @@ func main() {
6666
// Renames bare system keys (BEARER_TOKEN_, REGISTRY_OAUTH_, etc.) to __thv_<scope>_ namespace
6767
migration.CheckAndPerformSecretScopeMigration()
6868

69-
// Ensure default group exists (creates it for fresh installs, no-op otherwise)
70-
migration.EnsureDefaultGroupExists()
69+
// Ensure the default group exists on fresh installs so that commands
70+
// which default to --group default (e.g. run, list) work without the
71+
// user having to create the group manually.
72+
if err := migration.EnsureDefaultGroupExists(); err != nil {
73+
slog.Error("failed to ensure default group exists", "error", err)
74+
os.Exit(1)
75+
}
7176
}
7277

7378
cmd := app.NewRootCmd(!app.IsCompletionCommand(os.Args))

docs/cli/thv.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/thv_vmcp.md

Lines changed: 39 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/thv_vmcp_serve.md

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/thv_vmcp_validate.md

Lines changed: 44 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/migration/migration.go

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,22 @@ package migration
77
import (
88
"context"
99
"log/slog"
10-
"sync"
1110

11+
"github.com/stacklok/toolhive/pkg/container/runtime"
1212
"github.com/stacklok/toolhive/pkg/groups"
1313
)
1414

15-
// ensureDefaultGroupOnce ensures the default group check only runs once per process
16-
var ensureDefaultGroupOnce sync.Once
17-
1815
// EnsureDefaultGroupExists ensures the default group exists, creating it if necessary.
1916
// This is called at application startup for fresh installs and is a no-op when
2017
// the group already exists (e.g. after a previous migration or existing setup).
21-
func EnsureDefaultGroupExists() {
22-
ensureDefaultGroupOnce.Do(func() {
23-
if err := ensureDefaultGroupExists(context.Background()); err != nil {
24-
slog.Error("failed to ensure default group exists", "error", err)
25-
return
26-
}
27-
})
18+
// In Kubernetes environments this is always a no-op: MCPGroup CRDs are
19+
// operator/user-managed resources and the caller's service account may not
20+
// have create permission on them.
21+
func EnsureDefaultGroupExists() error {
22+
if runtime.IsKubernetesRuntime() {
23+
return nil
24+
}
25+
return ensureDefaultGroupExists(context.Background())
2826
}
2927

3028
func ensureDefaultGroupExists(ctx context.Context) error {

pkg/vmcp/aggregator/discoverer.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ func (d *backendDiscoverer) Discover(ctx context.Context, groupRef string) (back
160160
return nil, fmt.Errorf("failed to check if group exists: %w", err)
161161
}
162162
if !exists {
163-
return nil, fmt.Errorf("group %s not found", groupRef)
163+
return nil, fmt.Errorf("%w: %s", groups.ErrGroupNotFound, groupRef)
164164
}
165165

166166
// Get all typedWorkloads in the group

pkg/vmcp/cli/serve.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/stacklok/toolhive/pkg/authserver/server/keys"
2929
"github.com/stacklok/toolhive/pkg/container/runtime"
3030
"github.com/stacklok/toolhive/pkg/groups"
31+
"github.com/stacklok/toolhive/pkg/migration"
3132
"github.com/stacklok/toolhive/pkg/telemetry"
3233
"github.com/stacklok/toolhive/pkg/versions"
3334
"github.com/stacklok/toolhive/pkg/vmcp"
@@ -418,8 +419,14 @@ func discoverBackends(
418419
cfg.Group,
419420
)
420421
} else {
421-
// Dynamic mode: discover backends at runtime from K8s API.
422+
// Dynamic mode: discover backends at runtime from the active workload manager (K8s or local).
422423
slog.Info("dynamic mode: initializing group manager for backend discovery")
424+
// EnsureDefaultGroupExists is a no-op in Kubernetes (service account has no
425+
// create permission on MCPGroup CRDs). If the group does not exist,
426+
// Discover returns ErrGroupNotFound which is handled below.
427+
if err := migration.EnsureDefaultGroupExists(); err != nil {
428+
return nil, nil, nil, fmt.Errorf("failed to ensure default group exists: %w", err)
429+
}
423430
groupsManager, err := groups.NewManager()
424431
if err != nil {
425432
return nil, nil, nil, fmt.Errorf("failed to create groups manager: %w", err)
@@ -447,6 +454,13 @@ func runDiscovery(
447454
slog.Info(fmt.Sprintf("Discovering backends in group: %s", groupRef))
448455
backends, err := discoverer.Discover(ctx, groupRef)
449456
if err != nil {
457+
// In Kubernetes mode the MCPGroup CRD is operator/user-managed and may
458+
// not exist yet. Treat a missing group as zero backends so vMCP can
459+
// start and serve once backends are registered later.
460+
if runtime.IsKubernetesRuntime() && errors.Is(err, groups.ErrGroupNotFound) {
461+
slog.Warn(fmt.Sprintf("Group %s not found - vmcp will start but have no backends to proxy", groupRef))
462+
return []vmcp.Backend{}, backendClient, outgoingRegistry, nil
463+
}
450464
return nil, nil, nil, fmt.Errorf("failed to discover backends: %w", err)
451465
}
452466

0 commit comments

Comments
 (0)