feat: generate config field helpers

This commit is contained in:
thibaud-lclr 2026-05-02 12:02:23 +02:00
parent a79f73825f
commit 17b1b99686
6 changed files with 445 additions and 0 deletions

View file

@ -20,6 +20,7 @@ mcpgen/
metadata.go
update.go
secretstore.go
config.go # si [[config.fields]] existe
```
Le package généré expose le loader de manifeste :
@ -74,6 +75,24 @@ func PreflightSecretStore(options SecretStoreOptions) (secretstore.PreflightRepo
`BitwardenDebug`, `Shell`, `ExecutableResolver`). Si `ServiceName` est vide,
le nom du binaire déclaré dans le manifeste est utilisé.
Si le manifest déclare `[[config.fields]]`, le package généré expose aussi :
```go
type ConfigFlags struct { /* champs internes */ }
func AddConfigFlags(fs *flag.FlagSet) ConfigFlags
func ConfigFlagValues(flags ConfigFlags) map[string]string
func ResolveFieldSpecs(profile string) []cli.FieldSpec
func SetupFields(existing map[string]string) []cli.SetupField
```
`AddConfigFlags` branche les flags déclarés sur le `FlagSet` du projet.
`ConfigFlagValues` retourne uniquement les valeurs de flags non vides.
`ResolveFieldSpecs` génère les specs à passer à `cli.ResolveFields`, en
remplaçant `{profile}` dans les templates de secrets. `SetupFields` génère les
champs attendus par `cli.RunSetup`; le paramètre `existing` permet de fournir
les secrets déjà stockés par nom de champ.
## Flags
- `--manifest` : chemin du `mcp.toml` à lire. Par défaut, `./mcp.toml`.

View file

@ -36,6 +36,26 @@ known = ["dev", "staging", "prod"]
[bootstrap]
description = "Client MCP interne"
[[config.fields]]
name = "base_url"
flag = "base-url"
env = "MY_MCP_URL"
config_key = "base_url"
type = "url"
label = "Base URL"
required = true
sources = ["flag", "env", "config"]
[[config.fields]]
name = "api_token"
flag = "api-token"
env = "MY_MCP_TOKEN"
secret_key_template = "profile/{profile}/api-token"
type = "secret"
label = "API token"
required = true
sources = ["flag", "env", "secret"]
```
Champs supportés :
@ -63,6 +83,21 @@ Champs supportés :
- `[profiles].default` : profil recommandé par défaut.
- `[profiles].known` : profils connus du projet.
- `[bootstrap].description` : description CLI utilisée par le bootstrap.
- `[[config.fields]]` : champs de configuration déclaratifs consommés par
`mcp-framework generate`.
- `name` : identifiant stable du champ.
- `flag` : nom du flag CLI, sans `--`.
- `env` : variable d'environnement associée.
- `config_key` : clé dans la config fichier du projet.
- `secret_key_template` : clé de secret, avec `{profile}` remplacé par le
profil courant dans le code généré.
- `type` : type de setup (`string`, `url`, `secret`, `bool`, `list`).
- `label` : libellé humain utilisé pendant le setup.
- `default` : valeur par défaut optionnelle.
- `required` : si `true`, la résolution échoue quand aucune source ne fournit
de valeur.
- `sources` : ordre de résolution spécifique au champ (`flag`, `env`,
`config`, `secret`).
Toutes ces sections (hors `[update]` selon les besoins de l'application) sont optionnelles.

View file

@ -62,6 +62,10 @@ func Generate(options Options) (Result, error) {
if err != nil {
return Result{}, err
}
config, err := renderConfig(normalized.PackageName, manifestFile.Config.Fields)
if err != nil {
return Result{}, err
}
files := []generatedFile{
{
@ -85,6 +89,13 @@ func Generate(options Options) (Result, error) {
Mode: 0o644,
},
}
if strings.TrimSpace(config) != "" {
files = append(files, generatedFile{
Path: filepath.Join(normalized.PackageDir, "config.go"),
Content: config,
Mode: 0o644,
})
}
written := make([]string, 0, len(files))
for _, file := range files {
@ -411,6 +422,164 @@ func secretStoreServiceName(options SecretStoreOptions) string {
return formatGenerated("secretstore", source)
}
func renderConfig(packageName string, fields []manifest.ConfigField) (string, error) {
if len(fields) == 0 {
return "", nil
}
var flagsBuilder strings.Builder
var specsBuilder strings.Builder
var setupBuilder strings.Builder
for _, field := range fields {
name := strings.TrimSpace(field.Name)
if name == "" {
return "", fmt.Errorf("generate config field: name must not be empty")
}
flagName := strings.TrimSpace(field.Flag)
if flagName != "" {
fmt.Fprintf(
&flagsBuilder,
"\tflags.values[%s] = fs.String(%s, \"\", %s)\n",
strconv.Quote(name),
strconv.Quote(flagName),
strconv.Quote(configFieldLabel(field)),
)
}
fmt.Fprintf(
&specsBuilder,
"\t\t{Name: %s, Required: %t, DefaultValue: %s, Sources: []fwcli.ValueSource{%s}, FlagKey: %s, EnvKey: %s, ConfigKey: %s, SecretKey: replaceProfile(%s, profile)},\n",
strconv.Quote(name),
field.Required,
strconv.Quote(field.Default),
configSourceList(field.Sources),
strconv.Quote(flagName),
strconv.Quote(field.Env),
strconv.Quote(field.ConfigKey),
strconv.Quote(field.SecretKeyTemplate),
)
fmt.Fprintf(
&setupBuilder,
"\t\t{Name: %s, Label: %s, Type: %s, Required: %t, Default: %s, ExistingSecret: existing[%s]},\n",
strconv.Quote(name),
strconv.Quote(configFieldLabel(field)),
configSetupFieldType(field.Type),
field.Required,
strconv.Quote(field.Default),
strconv.Quote(name),
)
}
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
import (
"flag"
"strings"
fwcli "gitea.lclr.dev/AI/mcp-framework/cli"
)
type ConfigFlags struct {
values map[string]*string
}
func AddConfigFlags(fs *flag.FlagSet) ConfigFlags {
if fs == nil {
fs = flag.CommandLine
}
flags := ConfigFlags{
values: make(map[string]*string),
}
%s
return flags
}
func ConfigFlagValues(flags ConfigFlags) map[string]string {
values := make(map[string]string)
for name, value := range flags.values {
if value == nil {
continue
}
if trimmed := strings.TrimSpace(*value); trimmed != "" {
values[name] = trimmed
}
}
return values
}
func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {
return []fwcli.FieldSpec{
%s
}
}
func SetupFields(existing map[string]string) []fwcli.SetupField {
if existing == nil {
existing = map[string]string{}
}
return []fwcli.SetupField{
%s
}
}
func replaceProfile(value, profile string) string {
return strings.ReplaceAll(value, "{profile}", strings.TrimSpace(profile))
}
`, packageName, flagsBuilder.String(), specsBuilder.String(), setupBuilder.String())
return formatGenerated("config", source)
}
func configFieldLabel(field manifest.ConfigField) string {
if label := strings.TrimSpace(field.Label); label != "" {
return label
}
return strings.TrimSpace(field.Name)
}
func configSourceList(sources []string) string {
if len(sources) == 0 {
return ""
}
parts := make([]string, 0, len(sources))
for _, source := range sources {
switch strings.TrimSpace(source) {
case "flag":
parts = append(parts, "fwcli.SourceFlag")
case "env":
parts = append(parts, "fwcli.SourceEnv")
case "config":
parts = append(parts, "fwcli.SourceConfig")
case "secret":
parts = append(parts, "fwcli.SourceSecret")
}
}
return strings.Join(parts, ", ")
}
func configSetupFieldType(fieldType string) string {
switch strings.TrimSpace(fieldType) {
case "url":
return "fwcli.SetupFieldURL"
case "secret":
return "fwcli.SetupFieldSecret"
case "bool":
return "fwcli.SetupFieldBool"
case "list":
return "fwcli.SetupFieldList"
default:
return "fwcli.SetupFieldString"
}
}
func formatGenerated(name, source string) (string, error) {
formatted, err := format.Source([]byte(source))
if err != nil {

View file

@ -130,6 +130,62 @@ description = "Demo MCP"
}
}
func TestGenerateCreatesConfigHelpersFromManifestFields(t *testing.T) {
projectDir := newProject(t, `
binary_name = "demo-mcp"
[[config.fields]]
name = "base_url"
flag = "base-url"
env = "BASE_URL"
config_key = "base_url"
type = "url"
label = "Graylog URL"
required = true
sources = ["flag", "env", "config"]
[[config.fields]]
name = "api_token"
flag = "api-token"
env = "API_TOKEN"
secret_key_template = "profile/{profile}/api-token"
type = "secret"
label = "API token"
required = true
sources = ["flag", "env", "secret"]
`)
result, err := Generate(Options{ProjectDir: projectDir})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
wantFiles := generatedFilesWithConfig("mcpgen")
if !slices.Equal(result.Files, wantFiles) {
t.Fatalf("result files = %v, want %v", result.Files, wantFiles)
}
config, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "config.go"))
if err != nil {
t.Fatalf("ReadFile config.go: %v", err)
}
for _, snippet := range []string{
"type ConfigFlags struct {",
"func AddConfigFlags(fs *flag.FlagSet) ConfigFlags {",
"func ConfigFlagValues(flags ConfigFlags) map[string]string {",
"func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {",
"func SetupFields(existing map[string]string) []fwcli.SetupField {",
`fs.String("base-url", "", "Graylog URL")`,
`SecretKey: replaceProfile("profile/{profile}/api-token", profile)`,
"fwcli.SetupFieldURL",
"fwcli.SetupFieldSecret",
} {
if !strings.Contains(string(config), snippet) {
t.Fatalf("config.go missing snippet %q:\n%s", snippet, config)
}
}
}
func TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) {
projectDir := newProject(t, `binary_name = "demo-mcp"`)
@ -227,6 +283,26 @@ backend_policy = "env-only"
[bootstrap]
description = "Embedded Demo"
[[config.fields]]
name = "base_url"
flag = "base-url"
env = "BASE_URL"
config_key = "base_url"
type = "url"
label = "Base URL"
required = true
sources = ["flag", "env", "config"]
[[config.fields]]
name = "api_token"
flag = "api-token"
env = "API_TOKEN"
secret_key_template = "profile/{profile}/api-token"
type = "secret"
label = "API token"
required = true
sources = ["flag", "env", "secret"]
`)
writeModule(t, projectDir)
@ -265,6 +341,16 @@ func defaultGeneratedFiles(packageDir string) []string {
}
}
func generatedFilesWithConfig(packageDir string) []string {
return []string{
filepath.Join(packageDir, "config.go"),
filepath.Join(packageDir, "manifest.go"),
filepath.Join(packageDir, "metadata.go"),
filepath.Join(packageDir, "secretstore.go"),
filepath.Join(packageDir, "update.go"),
}
}
func writeModule(t *testing.T, projectDir string) {
t.Helper()
@ -289,9 +375,11 @@ func writeModule(t *testing.T, projectDir string) {
testFile := `package main
import (
"flag"
"io"
"testing"
fwcli "gitea.lclr.dev/AI/mcp-framework/cli"
fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
"example.com/generated-demo/mcpgen"
fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
@ -352,6 +440,30 @@ func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) {
if fwsecretstore.EffectiveBackendPolicy(store) != fwsecretstore.BackendEnvOnly {
t.Fatalf("effective backend = %q", fwsecretstore.EffectiveBackendPolicy(store))
}
flags := mcpgen.AddConfigFlags(flag.NewFlagSet("test", flag.ContinueOnError))
if len(mcpgen.ConfigFlagValues(flags)) != 0 {
t.Fatalf("empty flags should not return values")
}
specs := mcpgen.ResolveFieldSpecs("default")
if len(specs) != 2 {
t.Fatalf("field specs = %d, want 2", len(specs))
}
if specs[1].SecretKey != "profile/default/api-token" {
t.Fatalf("secret key = %q", specs[1].SecretKey)
}
setupFields := mcpgen.SetupFields(map[string]string{"api_token": "stored"})
if len(setupFields) != 2 {
t.Fatalf("setup fields = %d, want 2", len(setupFields))
}
if setupFields[0].Type != fwcli.SetupFieldURL {
t.Fatalf("first setup field type = %q", setupFields[0].Type)
}
if setupFields[1].ExistingSecret != "stored" {
t.Fatalf("existing secret = %q", setupFields[1].ExistingSecret)
}
}
`
if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil {

View file

@ -23,6 +23,7 @@ type File struct {
SecretStore SecretStore `toml:"secret_store"`
Profiles Profiles `toml:"profiles"`
Bootstrap Bootstrap `toml:"bootstrap"`
Config Config `toml:"config"`
}
type Update struct {
@ -60,6 +61,23 @@ type Bootstrap struct {
Description string `toml:"description"`
}
type Config struct {
Fields []ConfigField `toml:"fields"`
}
type ConfigField struct {
Name string `toml:"name"`
Flag string `toml:"flag"`
Env string `toml:"env"`
ConfigKey string `toml:"config_key"`
SecretKeyTemplate string `toml:"secret_key_template"`
Type string `toml:"type"`
Label string `toml:"label"`
Default string `toml:"default"`
Required bool `toml:"required"`
Sources []string `toml:"sources"`
}
type BootstrapMetadata struct {
BinaryName string
Description string
@ -180,6 +198,7 @@ func (f *File) normalize() {
f.SecretStore.normalize()
f.Profiles.normalize()
f.Bootstrap.normalize()
f.Config.normalize()
}
func (u *Update) normalize() {
@ -215,6 +234,24 @@ func (b *Bootstrap) normalize() {
b.Description = strings.TrimSpace(b.Description)
}
func (c *Config) normalize() {
for i := range c.Fields {
c.Fields[i].normalize()
}
}
func (f *ConfigField) normalize() {
f.Name = strings.TrimSpace(f.Name)
f.Flag = strings.TrimSpace(f.Flag)
f.Env = strings.TrimSpace(f.Env)
f.ConfigKey = strings.TrimSpace(f.ConfigKey)
f.SecretKeyTemplate = strings.TrimSpace(f.SecretKeyTemplate)
f.Type = strings.ToLower(strings.TrimSpace(f.Type))
f.Label = strings.TrimSpace(f.Label)
f.Default = strings.TrimSpace(f.Default)
f.Sources = normalizeStringList(f.Sources)
}
func (u Update) ReleaseSource() update.ReleaseSource {
u.normalize()

View file

@ -235,6 +235,79 @@ description = " Client MCP interne "
}
}
func TestLoadParsesConfigFields(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, DefaultFile)
const content = `
[[config.fields]]
name = " base_url "
flag = "base-url"
env = "BASE_URL"
config_key = "base_url"
type = " url "
label = " Graylog URL "
required = true
sources = [" flag ", "env", "config"]
[[config.fields]]
name = "api_token"
flag = "api-token"
env = "API_TOKEN"
secret_key_template = "profile/{profile}/api-token"
type = "secret"
required = true
sources = ["flag", "env", "secret"]
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
file, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if len(file.Config.Fields) != 2 {
t.Fatalf("config fields = %d, want 2", len(file.Config.Fields))
}
baseURL := file.Config.Fields[0]
if baseURL.Name != "base_url" {
t.Fatalf("base URL name = %q", baseURL.Name)
}
if baseURL.Flag != "base-url" {
t.Fatalf("base URL flag = %q", baseURL.Flag)
}
if baseURL.Env != "BASE_URL" {
t.Fatalf("base URL env = %q", baseURL.Env)
}
if baseURL.ConfigKey != "base_url" {
t.Fatalf("base URL config key = %q", baseURL.ConfigKey)
}
if baseURL.Type != "url" {
t.Fatalf("base URL type = %q", baseURL.Type)
}
if baseURL.Label != "Graylog URL" {
t.Fatalf("base URL label = %q", baseURL.Label)
}
if !baseURL.Required {
t.Fatal("base URL should be required")
}
if !slices.Equal(baseURL.Sources, []string{"flag", "env", "config"}) {
t.Fatalf("base URL sources = %v", baseURL.Sources)
}
token := file.Config.Fields[1]
if token.SecretKeyTemplate != "profile/{profile}/api-token" {
t.Fatalf("token secret key template = %q", token.SecretKeyTemplate)
}
if !slices.Equal(token.Sources, []string{"flag", "env", "secret"}) {
t.Fatalf("token sources = %v", token.Sources)
}
}
func TestLoadEmbeddedParsesContent(t *testing.T) {
file, source, err := LoadEmbedded(`
[update]