diff --git a/docs/generate.md b/docs/generate.md index ebf0d03..7f82371 100644 --- a/docs/generate.md +++ b/docs/generate.md @@ -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`. diff --git a/docs/manifest.md b/docs/manifest.md index 5868c40..0f945c5 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -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. diff --git a/generate/generate.go b/generate/generate.go index 711cedf..e5d3669 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -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 { diff --git a/generate/generate_test.go b/generate/generate_test.go index 0375689..1420809 100644 --- a/generate/generate_test.go +++ b/generate/generate_test.go @@ -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 { diff --git a/manifest/manifest.go b/manifest/manifest.go index b35dcf5..59c1b88 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -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() diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 46b0ec0..087b006 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -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]