diff --git a/README.md b/README.md index 501a9d2..119084d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ go get gitea.lclr.dev/AI/mcp-framework - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. - `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`. - `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`. -- `manifest` : lecture de `mcp.toml` à la racine du projet et conversion vers `update.ReleaseSource`. +- `manifest` : lecture de `mcp.toml` à la racine du projet, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. - `secretstore` : lecture/écriture de secrets dans le wallet natif. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. @@ -89,21 +89,48 @@ courant puis remonte les répertoires parents jusqu'à trouver le fichier. Exemple minimal : ```toml +binary_name = "my-mcp" +docs_url = "https://docs.example.com/my-mcp" + [update] source_name = "Gitea releases" base_url = "https://gitea.example.com" latest_release_url = "https://gitea.example.com/api/v1/repos/org/repo/releases/latest" token_header = "Authorization" token_env_names = ["GITEA_TOKEN"] + +[environment] +known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"] + +[secret_store] +backend_policy = "auto" + +[profiles] +default = "prod" +known = ["dev", "staging", "prod"] + +[bootstrap] +description = "Client MCP interne" ``` -Champs supportés dans `[update]` : +Champs supportés : + +- `binary_name` : nom du binaire (utilisable par le bootstrap/scaffolding). +- `docs_url` : URL de documentation projet. +- `[update]` : source de release consommée par `update`. - `source_name` : nom humain de la source de release, utilisé dans certains messages d'erreur. - `base_url` : base de la forge ou du service de release. - `latest_release_url` : URL complète qui retourne la release la plus récente. - `token_header` : header HTTP à utiliser pour l'authentification. - `token_env_names` : liste de variables d'environnement candidates pour retrouver le token. +- `[environment].known` : variables d'environnement connues du projet. +- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`). +- `[profiles].default` : profil recommandé par défaut. +- `[profiles].known` : profils connus du projet. +- `[bootstrap].description` : description CLI utilisée par le bootstrap. + +Toutes ces sections (hors `[update]` selon les besoins de l'application) sont optionnelles. Exemple de chargement : @@ -115,6 +142,10 @@ if err != nil { fmt.Printf("manifest loaded from %s\n", path) source := file.Update.ReleaseSource() +bootstrapInfo := file.BootstrapInfo() +scaffoldInfo := file.ScaffoldInfo() +_ = bootstrapInfo +_ = scaffoldInfo ``` ## Config JSON @@ -516,5 +547,4 @@ func run(ctx context.Context, flagProfile string) error { ## Limites Actuelles -- le manifeste gère uniquement la section `[update]` - l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 7108942..3a14226 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -211,7 +211,14 @@ func trimArgs(args []string) []string { func runConfigCommand(ctx context.Context, opts Options, args []string) error { if len(args) == 0 { - return fmt.Errorf("%w: %s requires one of: %s, %s", ErrSubcommandRequired, CommandConfig, ConfigSubcommandShow, ConfigSubcommandTest) + return fmt.Errorf( + "%w: %s requires one of: %s, %s, %s", + ErrSubcommandRequired, + CommandConfig, + ConfigSubcommandShow, + ConfigSubcommandTest, + ConfigSubcommandDelete, + ) } subcommand := strings.TrimSpace(args[0]) diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index 29031ab..d3d34c1 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -68,6 +68,9 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) { if !errors.Is(err, ErrSubcommandRequired) { t.Fatalf("Run error = %v, want ErrSubcommandRequired", err) } + if !strings.Contains(err.Error(), "show, test, delete") { + t.Fatalf("Run error = %q, want mention of show, test, delete", err) + } } func TestRunPrintsVersionByDefault(t *testing.T) { diff --git a/manifest/manifest.go b/manifest/manifest.go index 2bb8ca1..21c0bcd 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -15,7 +15,13 @@ import ( const DefaultFile = "mcp.toml" type File struct { - Update Update `toml:"update"` + BinaryName string `toml:"binary_name"` + DocsURL string `toml:"docs_url"` + Update Update `toml:"update"` + Environment Environment `toml:"environment"` + SecretStore SecretStore `toml:"secret_store"` + Profiles Profiles `toml:"profiles"` + Bootstrap Bootstrap `toml:"bootstrap"` } type Update struct { @@ -26,6 +32,40 @@ type Update struct { TokenEnvNames []string `toml:"token_env_names"` } +type Environment struct { + Known []string `toml:"known"` +} + +type SecretStore struct { + BackendPolicy string `toml:"backend_policy"` +} + +type Profiles struct { + Default string `toml:"default"` + Known []string `toml:"known"` +} + +type Bootstrap struct { + Description string `toml:"description"` +} + +type BootstrapMetadata struct { + BinaryName string + Description string + DocsURL string + DefaultProfile string + Profiles []string +} + +type ScaffoldMetadata struct { + BinaryName string + DocsURL string + KnownEnvironmentVariables []string + SecretStorePolicy string + DefaultProfile string + Profiles []string +} + func Find(startDir string) (string, error) { dir := strings.TrimSpace(startDir) if dir == "" { @@ -92,7 +132,13 @@ func LoadDefault(startDir string) (File, string, error) { } func (f *File) normalize() { + f.BinaryName = strings.TrimSpace(f.BinaryName) + f.DocsURL = strings.TrimSpace(f.DocsURL) f.Update.normalize() + f.Environment.normalize() + f.SecretStore.normalize() + f.Profiles.normalize() + f.Bootstrap.normalize() } func (u *Update) normalize() { @@ -100,14 +146,24 @@ func (u *Update) normalize() { u.BaseURL = strings.TrimRight(strings.TrimSpace(u.BaseURL), "/") u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL) u.TokenHeader = strings.TrimSpace(u.TokenHeader) + u.TokenEnvNames = normalizeStringList(u.TokenEnvNames) +} - envNames := u.TokenEnvNames[:0] - for _, envName := range u.TokenEnvNames { - if trimmed := strings.TrimSpace(envName); trimmed != "" { - envNames = append(envNames, trimmed) - } - } - u.TokenEnvNames = envNames +func (e *Environment) normalize() { + e.Known = normalizeStringList(e.Known) +} + +func (s *SecretStore) normalize() { + s.BackendPolicy = strings.TrimSpace(s.BackendPolicy) +} + +func (p *Profiles) normalize() { + p.Default = strings.TrimSpace(p.Default) + p.Known = normalizeStringList(p.Known) +} + +func (b *Bootstrap) normalize() { + b.Description = strings.TrimSpace(b.Description) } func (u Update) ReleaseSource() update.ReleaseSource { @@ -121,3 +177,38 @@ func (u Update) ReleaseSource() update.ReleaseSource { TokenEnvNames: append([]string(nil), u.TokenEnvNames...), } } + +func (f File) BootstrapInfo() BootstrapMetadata { + f.normalize() + + return BootstrapMetadata{ + BinaryName: f.BinaryName, + Description: f.Bootstrap.Description, + DocsURL: f.DocsURL, + DefaultProfile: f.Profiles.Default, + Profiles: append([]string(nil), f.Profiles.Known...), + } +} + +func (f File) ScaffoldInfo() ScaffoldMetadata { + f.normalize() + + return ScaffoldMetadata{ + BinaryName: f.BinaryName, + DocsURL: f.DocsURL, + KnownEnvironmentVariables: append([]string(nil), f.Environment.Known...), + SecretStorePolicy: f.SecretStore.BackendPolicy, + DefaultProfile: f.Profiles.Default, + Profiles: append([]string(nil), f.Profiles.Known...), + } +} + +func normalizeStringList(values []string) []string { + normalized := values[:0] + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + normalized = append(normalized, trimmed) + } + } + return normalized +} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index cfd35ec..ebc9454 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "slices" "testing" ) @@ -114,3 +115,82 @@ func TestLoadReturnsParseError(t *testing.T) { t.Fatal("expected error") } } + +func TestLoadParsesExtendedManifestMetadata(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultFile) + + const content = ` +binary_name = " my-mcp " +docs_url = " https://docs.example.com/mcp " + +[update] +latest_release_url = "https://example.com/latest" + +[environment] +known = [" MCP_PROFILE ", "", "MCP_TOKEN"] + +[secret_store] +backend_policy = " auto " + +[profiles] +default = " prod " +known = [" default ", "", "prod"] + +[bootstrap] +description = " Client MCP interne " +` + + 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 file.BinaryName != "my-mcp" { + t.Fatalf("binary name = %q", file.BinaryName) + } + if file.DocsURL != "https://docs.example.com/mcp" { + t.Fatalf("docs URL = %q", file.DocsURL) + } + if !slices.Equal(file.Environment.Known, []string{"MCP_PROFILE", "MCP_TOKEN"}) { + t.Fatalf("environment known = %v", file.Environment.Known) + } + if file.SecretStore.BackendPolicy != "auto" { + t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy) + } + if file.Profiles.Default != "prod" { + t.Fatalf("default profile = %q", file.Profiles.Default) + } + if !slices.Equal(file.Profiles.Known, []string{"default", "prod"}) { + t.Fatalf("profiles known = %v", file.Profiles.Known) + } + if file.Bootstrap.Description != "Client MCP interne" { + t.Fatalf("bootstrap description = %q", file.Bootstrap.Description) + } + + bootstrap := file.BootstrapInfo() + if bootstrap.BinaryName != "my-mcp" { + t.Fatalf("bootstrap binary name = %q", bootstrap.BinaryName) + } + if bootstrap.DocsURL != "https://docs.example.com/mcp" { + t.Fatalf("bootstrap docs URL = %q", bootstrap.DocsURL) + } + if bootstrap.DefaultProfile != "prod" { + t.Fatalf("bootstrap default profile = %q", bootstrap.DefaultProfile) + } + if !slices.Equal(bootstrap.Profiles, []string{"default", "prod"}) { + t.Fatalf("bootstrap profiles = %v", bootstrap.Profiles) + } + + scaffold := file.ScaffoldInfo() + if scaffold.SecretStorePolicy != "auto" { + t.Fatalf("scaffold secret store policy = %q", scaffold.SecretStorePolicy) + } + if !slices.Equal(scaffold.KnownEnvironmentVariables, []string{"MCP_PROFILE", "MCP_TOKEN"}) { + t.Fatalf("scaffold known environment variables = %v", scaffold.KnownEnvironmentVariables) + } +}