feat: generate config field helpers
This commit is contained in:
parent
a79f73825f
commit
17b1b99686
6 changed files with 445 additions and 0 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue