Skip to content

Commit ee988f3

Browse files
yroblataskbot
andauthored
Add pkg/vmcp/cli/init.go for vMCP config scaffolding (#4903)
Implement Init(ctx, InitConfig) which discovers running workloads in a named ToolHive group via an injected Discoverer, renders a commented starter YAML config pre-populated with one backend entry per accessible workload, and writes the result to a file (0o600) or an io.Writer. Workloads returning nil from GetWorkloadAsVMCPBackend are skipped with a debug log. Empty groups produce a valid YAML skeleton with backends: []. Includes unit tests covering writer output, file output, nil-backend skipping, empty groups, discovery errors, YAML validity (loader + validator), file permissions, and input validation edge cases. Closes #4882 Co-authored-by: taskbot <taskbot@users.noreply.github.com>
1 parent b8b76cc commit ee988f3

2 files changed

Lines changed: 457 additions & 0 deletions

File tree

pkg/vmcp/cli/init.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cli
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"fmt"
10+
"io"
11+
"log/slog"
12+
"os"
13+
"slices"
14+
"strings"
15+
"text/template"
16+
17+
"gopkg.in/yaml.v3"
18+
19+
groupval "github.com/stacklok/toolhive-core/validation/group"
20+
"github.com/stacklok/toolhive/pkg/fileutils"
21+
vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config"
22+
"github.com/stacklok/toolhive/pkg/vmcp/workloads"
23+
)
24+
25+
// InitConfig holds all parameters for the Init command.
26+
// Discoverer is injected rather than constructed internally to enable unit testing.
27+
type InitConfig struct {
28+
// GroupName is the ToolHive group whose workloads are enumerated.
29+
GroupName string
30+
31+
// OutputPath is the file path to write the generated config.
32+
// If empty or "-", content is written to Writer.
33+
OutputPath string
34+
35+
// Writer is used when OutputPath is empty or "-".
36+
// Defaults to os.Stdout when nil.
37+
Writer io.Writer
38+
39+
// Discoverer resolves running workloads in the group.
40+
// In production, callers pass a *pkg/workloads.DiscovererAdapter (CLI) or
41+
// the Kubernetes discoverer from pkg/vmcp/workloads/k8s.go.
42+
Discoverer workloads.Discoverer
43+
}
44+
45+
// initTemplateData holds the data for the config template.
46+
type initTemplateData struct {
47+
ServerName string
48+
GroupName string
49+
Backends []vmcpconfig.StaticBackendConfig
50+
}
51+
52+
// configTemplate is the starter vMCP YAML template with inline comments.
53+
// Text/template delimiters ({{...}}) do not conflict with YAML's {workload}_
54+
// placeholder because Go templates require double braces.
55+
const configTemplate = "# Generated by `thv vmcp init`. Review and customize before use.\n" +
56+
`
57+
# name: unique identifier for this vMCP server instance.
58+
name: {{.ServerName}}
59+
60+
# groupRef: the ToolHive group whose workloads are aggregated.
61+
groupRef: {{.GroupName}}
62+
63+
# incomingAuth: controls how clients authenticate to this vMCP server.
64+
# type: anonymous disables client auth (suitable for local development).
65+
# Change to "oidc" for production deployments.
66+
incomingAuth:
67+
type: anonymous
68+
69+
# outgoingAuth: controls how this vMCP server authenticates to backends.
70+
# source: inline means auth config is fully specified here.
71+
outgoingAuth:
72+
source: inline
73+
74+
# aggregation: controls how tools from multiple backends are combined.
75+
# conflictResolution: prefix prepends the backend name to each tool name.
76+
aggregation:
77+
conflictResolution: prefix
78+
conflictResolutionConfig:
79+
prefixFormat: "{workload}_"
80+
81+
# backends: one entry per running workload discovered in the group.
82+
backends:{{if .Backends}}
83+
{{range .Backends}} - name: {{yamlStr .Name}}
84+
url: {{yamlStr .URL}}
85+
transport: {{yamlStr .Transport}}
86+
{{end}}{{else}} []
87+
{{end}}`
88+
89+
// parsedConfigTemplate is the configTemplate parsed once at package init with
90+
// the yamlStr function registered, avoiding repeated parsing on every call.
91+
var parsedConfigTemplate = template.Must(
92+
template.New("vmcp-init").Funcs(template.FuncMap{
93+
"yamlStr": yamlScalar,
94+
}).Parse(configTemplate),
95+
)
96+
97+
// Init discovers workloads in cfg.GroupName, renders a starter vMCP YAML
98+
// config file with one backend entry per accessible workload, and writes
99+
// the result to cfg.OutputPath or cfg.Writer.
100+
func Init(ctx context.Context, cfg InitConfig) error {
101+
if cfg.Discoverer == nil {
102+
return fmt.Errorf("discoverer is required")
103+
}
104+
if err := groupval.ValidateName(cfg.GroupName); err != nil {
105+
return fmt.Errorf("invalid group name: %w", err)
106+
}
107+
108+
workloadList, err := cfg.Discoverer.ListWorkloadsInGroup(ctx, cfg.GroupName)
109+
if err != nil {
110+
return fmt.Errorf("failed to list workloads in group %q: %w", cfg.GroupName, err)
111+
}
112+
113+
backends, err := resolveBackends(ctx, cfg.Discoverer, workloadList)
114+
if err != nil {
115+
return err
116+
}
117+
118+
rendered, err := renderConfig(initTemplateData{
119+
ServerName: cfg.GroupName + "-vmcp",
120+
GroupName: cfg.GroupName,
121+
Backends: backends,
122+
})
123+
if err != nil {
124+
return err
125+
}
126+
127+
return writeOutput(cfg, rendered)
128+
}
129+
130+
// yamlScalar marshals a string to a properly quoted/escaped YAML scalar value
131+
// using gopkg.in/yaml.v3, ensuring characters like '#' or ':' in names and
132+
// URLs do not corrupt the rendered YAML.
133+
func yamlScalar(v string) (string, error) {
134+
b, err := yaml.Marshal(v)
135+
if err != nil {
136+
return "", err
137+
}
138+
return strings.TrimRight(string(b), "\n"), nil
139+
}
140+
141+
// normalizeTransport maps known transport aliases to the canonical values accepted
142+
// by the static backend config validator. Returns the canonical value and true,
143+
// or ("", false) for unsupported transports.
144+
func normalizeTransport(t string) (string, bool) {
145+
switch t {
146+
case vmcpconfig.TransportSSE:
147+
return vmcpconfig.TransportSSE, true
148+
case vmcpconfig.TransportStreamableHTTP, "streamable":
149+
return vmcpconfig.TransportStreamableHTTP, true
150+
default:
151+
return "", false
152+
}
153+
}
154+
155+
// resolveBackends calls GetWorkloadAsVMCPBackend for each workload and collects
156+
// accessible backends, skipping those that return nil.
157+
func resolveBackends(
158+
ctx context.Context,
159+
disc workloads.Discoverer,
160+
workloadList []workloads.TypedWorkload,
161+
) ([]vmcpconfig.StaticBackendConfig, error) {
162+
var backends []vmcpconfig.StaticBackendConfig
163+
for _, wl := range workloadList {
164+
backend, err := disc.GetWorkloadAsVMCPBackend(ctx, wl)
165+
if err != nil {
166+
return nil, fmt.Errorf("failed to get backend for workload %q: %w", wl.Name, err)
167+
}
168+
if backend == nil {
169+
slog.Debug("skipping workload: not yet accessible", "workload", wl.Name)
170+
continue
171+
}
172+
transport, ok := normalizeTransport(backend.TransportType)
173+
if !ok {
174+
slog.Warn("skipping workload: unsupported transport type for static config",
175+
"workload", wl.Name, "transport", backend.TransportType)
176+
continue
177+
}
178+
backends = append(backends, vmcpconfig.StaticBackendConfig{
179+
Name: backend.Name,
180+
URL: backend.BaseURL,
181+
Transport: transport,
182+
})
183+
}
184+
slices.SortFunc(backends, func(a, b vmcpconfig.StaticBackendConfig) int {
185+
return strings.Compare(a.Name, b.Name)
186+
})
187+
return backends, nil
188+
}
189+
190+
// renderConfig executes the pre-parsed configTemplate with the given data.
191+
func renderConfig(data initTemplateData) ([]byte, error) {
192+
var buf bytes.Buffer
193+
if err := parsedConfigTemplate.Execute(&buf, data); err != nil {
194+
return nil, fmt.Errorf("failed to render config template: %w", err)
195+
}
196+
return buf.Bytes(), nil
197+
}
198+
199+
// writeOutput writes the rendered config to the configured destination.
200+
func writeOutput(cfg InitConfig, content []byte) error {
201+
if cfg.OutputPath != "" && cfg.OutputPath != "-" {
202+
if err := fileutils.AtomicWriteFile(cfg.OutputPath, content, 0o600); err != nil {
203+
return fmt.Errorf("failed to write config to %q: %w", cfg.OutputPath, err)
204+
}
205+
slog.Info("vMCP configuration written", "path", cfg.OutputPath)
206+
return nil
207+
}
208+
209+
w := cfg.Writer
210+
if w == nil {
211+
w = os.Stdout
212+
}
213+
if _, err := w.Write(content); err != nil {
214+
return fmt.Errorf("failed to write config: %w", err)
215+
}
216+
return nil
217+
}

0 commit comments

Comments
 (0)