From c0433d1cded736800c8a1af88834085bd55a41cd Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 08:17:58 +0200 Subject: [PATCH 01/79] docs(agents): enforce enhancement branch naming format --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1caefab..9cceb6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ Nommer la branche de manière explicite, par exemple : - `issue-8-update-drivers` - `docs-readme-installation` +Quand une branche est créée pour répondre à une issue et que cette issue porte le label `enhancement`, nommer la branche au format `feat/`. + Éviter de développer directement sur `main` quand le changement mérite une PR ou une validation fonctionnelle. ## Pull Requests -- 2.45.2 From 6badc79d4dab0c0108e072b4deefdc36e79afcfa Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 08:17:58 +0200 Subject: [PATCH 02/79] docs(agents): enforce enhancement branch naming format --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1caefab..9cceb6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ Nommer la branche de manière explicite, par exemple : - `issue-8-update-drivers` - `docs-readme-installation` +Quand une branche est créée pour répondre à une issue et que cette issue porte le label `enhancement`, nommer la branche au format `feat/`. + Éviter de développer directement sur `main` quand le changement mérite une PR ou une validation fonctionnelle. ## Pull Requests -- 2.45.2 From c70b3ac12b8d507a9231ea6bb292d5927133f450 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 08:24:24 +0200 Subject: [PATCH 03/79] feat: add optional CLI bootstrap package --- README.md | 52 ++++++-- bootstrap/bootstrap.go | 236 ++++++++++++++++++++++++++++++++++++ bootstrap/bootstrap_test.go | 182 +++++++++++++++++++++++++++ 3 files changed, 463 insertions(+), 7 deletions(-) create mode 100644 bootstrap/bootstrap.go create mode 100644 bootstrap/bootstrap_test.go diff --git a/README.md b/README.md index e8bcc12..3ffcb74 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ go get gitea.lclr.dev/AI/mcp-framework ## Packages +- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config`, `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`. @@ -29,12 +30,50 @@ go get gitea.lclr.dev/AI/mcp-framework Le flux typique côté application est : -1. Résoudre le profil actif avec `cli`. -2. Charger la config versionnée avec `config`. -3. Lire les secrets avec `secretstore`. -4. Charger `mcp.toml` avec `manifest`. -5. Exécuter l'auto-update avec `update` si nécessaire. -6. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. +1. Déclarer les sous-commandes communes via `bootstrap` (optionnel). +2. Résoudre le profil actif avec `cli`. +3. Charger la config versionnée avec `config`. +4. Lire les secrets avec `secretstore`. +5. Charger `mcp.toml` avec `manifest`. +6. Exécuter l'auto-update avec `update` si nécessaire. +7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. + +## Bootstrap CLI + +Le package `bootstrap` reste optionnel : une application peut l'utiliser pour +uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique. + +Exemple minimal : + +```go +func main() { + err := bootstrap.Run(context.Background(), bootstrap.Options{ + BinaryName: "my-mcp", + Description: "Client MCP", + Version: version, + Hooks: bootstrap.Hooks{ + Setup: func(ctx context.Context, inv bootstrap.Invocation) error { + return runSetup(ctx, inv.Args) + }, + MCP: func(ctx context.Context, inv bootstrap.Invocation) error { + return runMCP(ctx, inv.Args) + }, + Config: func(ctx context.Context, inv bootstrap.Invocation) error { + return runConfig(ctx, inv.Args) + }, + Update: func(ctx context.Context, inv bootstrap.Invocation) error { + return runUpdate(ctx, inv.Args) + }, + }, + }) + if err != nil { + log.Fatal(err) + } +} +``` + +Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche +automatiquement `Options.Version`. ## Manifeste `mcp.toml` @@ -371,5 +410,4 @@ func run(ctx context.Context, flagProfile string) error { ## Limites Actuelles - le manifeste gère uniquement la section `[update]` -- le framework ne fournit pas encore d'interface unique de bootstrap - l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go new file mode 100644 index 0000000..7e4854d --- /dev/null +++ b/bootstrap/bootstrap.go @@ -0,0 +1,236 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" +) + +const ( + CommandSetup = "setup" + CommandMCP = "mcp" + CommandConfig = "config" + CommandUpdate = "update" + CommandVersion = "version" +) + +var ( + ErrBinaryNameRequired = errors.New("binary name is required") + ErrUnknownCommand = errors.New("unknown command") + ErrCommandNotConfigured = errors.New("command not configured") + ErrVersionRequired = errors.New("version is required when no version hook is configured") +) + +type Handler func(context.Context, Invocation) error + +type Hooks struct { + Setup Handler + MCP Handler + Config Handler + Update Handler + Version Handler +} + +type Options struct { + BinaryName string + Description string + Version string + Args []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Hooks Hooks +} + +type Invocation struct { + Command string + Args []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +type commandDef struct { + Name string + Description string + Handler func(Hooks) Handler +} + +var commands = []commandDef{ + { + Name: CommandSetup, + Description: "Initialiser ou mettre a jour la configuration locale.", + Handler: func(h Hooks) Handler { + return h.Setup + }, + }, + { + Name: CommandMCP, + Description: "Executer la logique MCP principale du binaire.", + Handler: func(h Hooks) Handler { + return h.MCP + }, + }, + { + Name: CommandConfig, + Description: "Inspecter ou modifier la configuration.", + Handler: func(h Hooks) Handler { + return h.Config + }, + }, + { + Name: CommandUpdate, + Description: "Declencher le flux d'auto-update.", + Handler: func(h Hooks) Handler { + return h.Update + }, + }, + { + Name: CommandVersion, + Description: "Afficher la version du binaire.", + Handler: func(h Hooks) Handler { + return h.Version + }, + }, +} + +func Run(ctx context.Context, opts Options) error { + normalized := normalize(opts) + + if strings.TrimSpace(normalized.BinaryName) == "" { + return ErrBinaryNameRequired + } + + command, commandArgs, showHelp := parseArgs(normalized.Args) + if showHelp { + return printHelp(normalized, command) + } + + if command == "" { + return printHelp(normalized, "") + } + + handler, known := resolveHandler(command, normalized.Hooks) + if !known { + return fmt.Errorf("%w: %s", ErrUnknownCommand, command) + } + + if handler == nil { + if command == CommandVersion { + if strings.TrimSpace(normalized.Version) == "" { + return ErrVersionRequired + } + _, err := fmt.Fprintln(normalized.Stdout, normalized.Version) + return err + } + return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command) + } + + return handler(ctx, Invocation{ + Command: command, + Args: commandArgs, + Stdin: normalized.Stdin, + Stdout: normalized.Stdout, + Stderr: normalized.Stderr, + }) +} + +func normalize(opts Options) Options { + if opts.Stdin == nil { + opts.Stdin = os.Stdin + } + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.Stderr == nil { + opts.Stderr = os.Stderr + } + if opts.Args == nil { + opts.Args = os.Args[1:] + } + return opts +} + +func parseArgs(args []string) (command string, commandArgs []string, showHelp bool) { + if len(args) == 0 { + return "", nil, false + } + + first := strings.TrimSpace(args[0]) + switch first { + case "help", "-h", "--help": + if len(args) > 1 { + return strings.TrimSpace(args[1]), nil, true + } + return "", nil, true + } + + command = first + commandArgs = args[1:] + if len(commandArgs) == 1 { + helpArg := strings.TrimSpace(commandArgs[0]) + if helpArg == "-h" || helpArg == "--help" { + return command, nil, true + } + } + + return command, commandArgs, false +} + +func resolveHandler(command string, hooks Hooks) (Handler, bool) { + for _, def := range commands { + if def.Name == command { + return def.Handler(hooks), true + } + } + return nil, false +} + +func printHelp(opts Options, command string) error { + if command == "" { + return printGlobalHelp(opts) + } + + for _, def := range commands { + if def.Name != command { + continue + } + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s %s [args]\n\n%s\n", + opts.BinaryName, + def.Name, + def.Description, + ) + return err + } + + return fmt.Errorf("%w: %s", ErrUnknownCommand, command) +} + +func printGlobalHelp(opts Options) error { + if strings.TrimSpace(opts.Description) != "" { + if _, err := fmt.Fprintf(opts.Stdout, "%s\n\n", opts.Description); err != nil { + return err + } + } + + if _, err := fmt.Fprintf(opts.Stdout, "Usage:\n %s [args]\n\n", opts.BinaryName); err != nil { + return err + } + if _, err := fmt.Fprintln(opts.Stdout, "Commandes communes:"); err != nil { + return err + } + + for _, def := range commands { + if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", def.Name, def.Description); err != nil { + return err + } + } + + _, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help \n", opts.BinaryName) + return err +} diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..b0c6b4d --- /dev/null +++ b/bootstrap/bootstrap_test.go @@ -0,0 +1,182 @@ +package bootstrap + +import ( + "bytes" + "context" + "errors" + "slices" + "strings" + "testing" +) + +func TestRunRoutesSetupHook(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + var got Invocation + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Version: "v1.2.3", + Args: []string{"setup", "--profile", "prod"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Setup: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != CommandSetup { + t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup) + } + wantArgs := []string{"--profile", "prod"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } +} + +func TestRunReturnsUnknownCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"boom"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrUnknownCommand) { + t.Fatalf("Run error = %v, want ErrUnknownCommand", err) + } +} + +func TestRunReturnsCommandNotConfigured(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrCommandNotConfigured) { + t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err) + } +} + +func TestRunPrintsVersionByDefault(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Version: "v1.2.3", + Args: []string{"version"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + if stdout.String() != "v1.2.3\n" { + t.Fatalf("stdout = %q, want %q", stdout.String(), "v1.2.3\n") + } +} + +func TestRunVersionHookOverridesDefault(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"version"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Version: func(_ context.Context, inv Invocation) error { + _, err := inv.Stdout.Write([]byte("custom-version\n")) + return err + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + if stdout.String() != "custom-version\n" { + t.Fatalf("stdout = %q, want %q", stdout.String(), "custom-version\n") + } +} + +func TestRunRequiresVersionWithoutVersionHook(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"version"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrVersionRequired) { + t.Fatalf("Run error = %v, want ErrVersionRequired", err) + } +} + +func TestRunPrintsGlobalHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Description: "Binaire MCP de test.", + Args: []string{"help"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + for _, snippet := range []string{ + "Usage:", + "setup", + "mcp", + "config", + "update", + "version", + } { + if !strings.Contains(text, snippet) { + t.Fatalf("help output missing %q: %s", snippet, text) + } + } +} + +func TestRunPrintsCommandHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"help", "update"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp update [args]") { + t.Fatalf("command help output = %q", text) + } + if !strings.Contains(text, "auto-update") { + t.Fatalf("command help output missing update description: %q", text) + } +} -- 2.45.2 From 76fa01c6696e342fde14f2fac319784c7ced99b3 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 08:55:01 +0200 Subject: [PATCH 04/79] feat(cli): standardize config resolution and provenance --- README.md | 49 ++++++++ cli/resolve.go | 283 ++++++++++++++++++++++++++++++++++++++++++ cli/resolve_test.go | 291 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 623 insertions(+) create mode 100644 cli/resolve.go create mode 100644 cli/resolve_test.go diff --git a/README.md b/README.md index 3ffcb74..51c79cd 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,55 @@ if err != nil { } ``` +Pour standardiser la résolution `flag > env > config > secret` avec provenance : + +```go +lookup := func(source cli.ValueSource, key string) (string, bool, error) { + switch source { + case cli.SourceFlag: + value, ok := flagValues[key] + return value, ok, nil + case cli.SourceEnv: + value, ok := os.LookupEnv(key) + return value, ok, nil + case cli.SourceConfig: + value, ok := configValues[key] + return value, ok, nil + case cli.SourceSecret: + value, err := store.GetSecret(key) + switch { + case err == nil: + return value, true, nil + case errors.Is(err, secretstore.ErrNotFound): + return "", false, nil + default: + return "", false, err + } + default: + return "", false, nil + } +} + +resolution, err := cli.ResolveFields(cli.ResolveOptions{ + Fields: []cli.FieldSpec{ + {Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"}, + {Name: "api_token", Required: true, EnvKey: "MY_MCP_API_TOKEN", SecretKey: "my-mcp-api-token"}, + {Name: "timeout", DefaultValue: "30s"}, + }, + Lookup: lookup, +}) +if err != nil { + return err +} + +if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil { + return err +} +``` + +`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et +`FieldSpec.Sources` permet de définir un ordre spécifique pour un champ. + Le package fournit aussi un socle réutilisable pour une commande `doctor`. L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), mais peut réutiliser les checks communs et ajouter ses propres hooks : diff --git a/cli/resolve.go b/cli/resolve.go new file mode 100644 index 0000000..725571b --- /dev/null +++ b/cli/resolve.go @@ -0,0 +1,283 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "slices" + "strings" +) + +type ValueSource string + +const ( + SourceFlag ValueSource = "flag" + SourceEnv ValueSource = "env" + SourceConfig ValueSource = "config" + SourceSecret ValueSource = "secret" + SourceDefault ValueSource = "default" +) + +var DefaultResolutionOrder = []ValueSource{ + SourceFlag, + SourceEnv, + SourceConfig, + SourceSecret, +} + +var ErrInvalidResolverInput = errors.New("invalid resolver input") + +type FieldSpec struct { + Name string + Required bool + DefaultValue string + Sources []ValueSource + FlagKey string + EnvKey string + ConfigKey string + SecretKey string +} + +type LookupFunc func(source ValueSource, key string) (string, bool, error) + +type ResolveOptions struct { + Fields []FieldSpec + Order []ValueSource + Lookup LookupFunc +} + +type ResolvedField struct { + Name string + Value string + Source ValueSource + Found bool +} + +type Resolution struct { + Fields []ResolvedField +} + +func (r Resolution) Get(name string) (ResolvedField, bool) { + needle := strings.TrimSpace(name) + for _, field := range r.Fields { + if field.Name == needle { + return field, true + } + } + return ResolvedField{}, false +} + +type MissingRequiredValuesError struct { + Fields []string +} + +func (e *MissingRequiredValuesError) Error() string { + return fmt.Sprintf("missing required configuration values: %s", strings.Join(e.Fields, ", ")) +} + +type SourceLookupError struct { + Field string + Source ValueSource + Key string + Err error +} + +func (e *SourceLookupError) Error() string { + return fmt.Sprintf( + "resolve %q from %q (key %q): %v", + e.Field, + e.Source, + e.Key, + e.Err, + ) +} + +func (e *SourceLookupError) Unwrap() error { + return e.Err +} + +type StaticLookup struct { + Flags map[string]string + Env map[string]string + Config map[string]string + Secrets map[string]string +} + +func (l StaticLookup) Lookup(source ValueSource, key string) (string, bool, error) { + var values map[string]string + switch source { + case SourceFlag: + values = l.Flags + case SourceEnv: + values = l.Env + case SourceConfig: + values = l.Config + case SourceSecret: + values = l.Secrets + case SourceDefault: + return "", false, fmt.Errorf("%w: source %q is reserved", ErrInvalidResolverInput, source) + default: + return "", false, fmt.Errorf("%w: unknown source %q", ErrInvalidResolverInput, source) + } + + value, ok := values[key] + return value, ok, nil +} + +func ResolveFields(options ResolveOptions) (Resolution, error) { + if options.Lookup == nil { + return Resolution{}, fmt.Errorf("%w: lookup is required", ErrInvalidResolverInput) + } + + globalOrder, err := normalizeOrder(options.Order, DefaultResolutionOrder) + if err != nil { + return Resolution{}, fmt.Errorf("%w: %v", ErrInvalidResolverInput, err) + } + + resolution := Resolution{ + Fields: make([]ResolvedField, 0, len(options.Fields)), + } + missingRequired := make([]string, 0) + seenNames := make(map[string]struct{}, len(options.Fields)) + + for i, spec := range options.Fields { + name := strings.TrimSpace(spec.Name) + if name == "" { + return Resolution{}, fmt.Errorf("%w: field at index %d has empty name", ErrInvalidResolverInput, i) + } + if _, exists := seenNames[name]; exists { + return Resolution{}, fmt.Errorf("%w: duplicate field name %q", ErrInvalidResolverInput, name) + } + seenNames[name] = struct{}{} + + order := globalOrder + if len(spec.Sources) > 0 { + order, err = normalizeOrder(spec.Sources, globalOrder) + if err != nil { + return Resolution{}, fmt.Errorf("%w: field %q: %v", ErrInvalidResolverInput, name, err) + } + } + + field := ResolvedField{Name: name} + for _, source := range order { + key := spec.keyFor(source) + if key == "" { + continue + } + + value, found, err := options.Lookup(source, key) + if err != nil { + return resolution, &SourceLookupError{ + Field: name, + Source: source, + Key: key, + Err: err, + } + } + + trimmed := strings.TrimSpace(value) + if found && trimmed != "" { + field.Value = trimmed + field.Source = source + field.Found = true + break + } + } + + if !field.Found { + defaultValue := strings.TrimSpace(spec.DefaultValue) + if defaultValue != "" { + field.Value = defaultValue + field.Source = SourceDefault + field.Found = true + } + } + + if spec.Required && !field.Found { + missingRequired = append(missingRequired, name) + } + resolution.Fields = append(resolution.Fields, field) + } + + if len(missingRequired) > 0 { + return resolution, &MissingRequiredValuesError{Fields: missingRequired} + } + + return resolution, nil +} + +func RenderResolutionProvenance(w io.Writer, resolution Resolution) error { + for _, field := range resolution.Fields { + source := "missing" + if field.Found { + source = string(field.Source) + } + + if _, err := fmt.Fprintf(w, "%s: %s\n", field.Name, source); err != nil { + return err + } + } + + return nil +} + +func normalizeOrder(input []ValueSource, fallback []ValueSource) ([]ValueSource, error) { + order := input + if len(order) == 0 { + order = fallback + } + + result := make([]ValueSource, 0, len(order)) + seen := make(map[ValueSource]struct{}, len(order)) + for _, source := range order { + if !isKnownSource(source) { + return nil, fmt.Errorf("unknown source %q", source) + } + if source == SourceDefault { + return nil, fmt.Errorf("source %q cannot be used in resolution order", source) + } + if _, exists := seen[source]; exists { + continue + } + + seen[source] = struct{}{} + result = append(result, source) + } + + if len(result) == 0 { + return nil, fmt.Errorf("resolution order is empty") + } + + return slices.Clone(result), nil +} + +func isKnownSource(source ValueSource) bool { + switch source { + case SourceFlag, SourceEnv, SourceConfig, SourceSecret, SourceDefault: + return true + default: + return false + } +} + +func (s FieldSpec) keyFor(source ValueSource) string { + switch source { + case SourceFlag: + return fallbackKey(s.FlagKey, s.Name) + case SourceEnv: + return fallbackKey(s.EnvKey, s.Name) + case SourceConfig: + return fallbackKey(s.ConfigKey, s.Name) + case SourceSecret: + return fallbackKey(s.SecretKey, s.Name) + default: + return "" + } +} + +func fallbackKey(explicit, fallback string) string { + if key := strings.TrimSpace(explicit); key != "" { + return key + } + return strings.TrimSpace(fallback) +} diff --git a/cli/resolve_test.go b/cli/resolve_test.go new file mode 100644 index 0000000..27792fe --- /dev/null +++ b/cli/resolve_test.go @@ -0,0 +1,291 @@ +package cli + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestResolveFieldsUsesDefaultOrderAndTracksSource(t *testing.T) { + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "base_url", + Required: true, + FlagKey: "base-url", + }, + { + Name: "api_token", + Required: true, + EnvKey: "API_TOKEN", + SecretKey: "my-api-token", + }, + { + Name: "stream_id", + }, + }, + Lookup: StaticLookup{ + Flags: map[string]string{ + "base-url": "https://flag.example.com", + }, + Env: map[string]string{ + "base_url": "https://env.example.com", + "API_TOKEN": "", + }, + Config: map[string]string{ + "stream_id": "stream-from-config", + }, + Secrets: map[string]string{ + "my-api-token": "secret-token", + }, + }.Lookup, + }) + if err != nil { + t.Fatalf("ResolveFields returned error: %v", err) + } + + baseURL, ok := resolution.Get("base_url") + if !ok { + t.Fatalf("base_url was not resolved") + } + if !baseURL.Found { + t.Fatalf("base_url must be found") + } + if baseURL.Source != SourceFlag { + t.Fatalf("base_url source = %q, want %q", baseURL.Source, SourceFlag) + } + if baseURL.Value != "https://flag.example.com" { + t.Fatalf("base_url value = %q", baseURL.Value) + } + + apiToken, ok := resolution.Get("api_token") + if !ok { + t.Fatalf("api_token was not resolved") + } + if apiToken.Source != SourceSecret { + t.Fatalf("api_token source = %q, want %q", apiToken.Source, SourceSecret) + } + if apiToken.Value != "secret-token" { + t.Fatalf("api_token value = %q", apiToken.Value) + } + + streamID, ok := resolution.Get("stream_id") + if !ok { + t.Fatalf("stream_id was not resolved") + } + if streamID.Source != SourceConfig { + t.Fatalf("stream_id source = %q, want %q", streamID.Source, SourceConfig) + } + if streamID.Value != "stream-from-config" { + t.Fatalf("stream_id value = %q", streamID.Value) + } +} + +func TestResolveFieldsSupportsCustomGlobalOrder(t *testing.T) { + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "base_url", + Required: true, + }, + }, + Order: []ValueSource{SourceConfig, SourceEnv, SourceFlag}, + Lookup: StaticLookup{ + Flags: map[string]string{ + "base_url": "https://flag.example.com", + }, + Env: map[string]string{ + "base_url": "https://env.example.com", + }, + Config: map[string]string{ + "base_url": "https://config.example.com", + }, + }.Lookup, + }) + if err != nil { + t.Fatalf("ResolveFields returned error: %v", err) + } + + baseURL, _ := resolution.Get("base_url") + if baseURL.Source != SourceConfig { + t.Fatalf("base_url source = %q, want %q", baseURL.Source, SourceConfig) + } +} + +func TestResolveFieldsSupportsPerFieldOrderAndDefaultValues(t *testing.T) { + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "api_token", + Required: true, + Sources: []ValueSource{SourceSecret, SourceEnv}, + SecretKey: "API_TOKEN_SECRET", + DefaultValue: "default-token", + }, + { + Name: "region", + DefaultValue: "eu-west", + }, + }, + Lookup: StaticLookup{ + Env: map[string]string{ + "api_token": "env-token", + }, + Secrets: map[string]string{ + "API_TOKEN_SECRET": "secret-token", + }, + }.Lookup, + }) + if err != nil { + t.Fatalf("ResolveFields returned error: %v", err) + } + + apiToken, _ := resolution.Get("api_token") + if apiToken.Source != SourceSecret { + t.Fatalf("api_token source = %q, want %q", apiToken.Source, SourceSecret) + } + if apiToken.Value != "secret-token" { + t.Fatalf("api_token value = %q", apiToken.Value) + } + + region, _ := resolution.Get("region") + if region.Source != SourceDefault { + t.Fatalf("region source = %q, want %q", region.Source, SourceDefault) + } + if region.Value != "eu-west" { + t.Fatalf("region value = %q, want eu-west", region.Value) + } +} + +func TestResolveFieldsReturnsHomogeneousMissingRequiredError(t *testing.T) { + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + {Name: "base_url", Required: true}, + {Name: "api_token", Required: true}, + {Name: "region"}, + }, + Lookup: StaticLookup{}.Lookup, + }) + + var missingErr *MissingRequiredValuesError + if !errors.As(err, &missingErr) { + t.Fatalf("ResolveFields error = %v, want MissingRequiredValuesError", err) + } + + if got := strings.Join(missingErr.Fields, ","); got != "base_url,api_token" { + t.Fatalf("missing fields = %q, want base_url,api_token", got) + } + + region, ok := resolution.Get("region") + if !ok { + t.Fatalf("region should exist in partial resolution") + } + if region.Found { + t.Fatalf("region should not be found") + } +} + +func TestResolveFieldsWrapsLookupErrorsWithContext(t *testing.T) { + lookupErr := errors.New("secret backend unavailable") + _, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "api_token", + Sources: []ValueSource{SourceSecret}, + }, + }, + Lookup: func(source ValueSource, key string) (string, bool, error) { + return "", false, lookupErr + }, + }) + + var sourceErr *SourceLookupError + if !errors.As(err, &sourceErr) { + t.Fatalf("ResolveFields error = %v, want SourceLookupError", err) + } + if sourceErr.Field != "api_token" { + t.Fatalf("sourceErr.Field = %q, want api_token", sourceErr.Field) + } + if sourceErr.Source != SourceSecret { + t.Fatalf("sourceErr.Source = %q, want %q", sourceErr.Source, SourceSecret) + } + if !errors.Is(err, lookupErr) { + t.Fatalf("ResolveFields error should wrap the original lookup error") + } +} + +func TestResolveFieldsRejectsInvalidDefinitions(t *testing.T) { + tests := []struct { + name string + options ResolveOptions + }{ + { + name: "missing lookup", + options: ResolveOptions{ + Fields: []FieldSpec{{Name: "base_url"}}, + }, + }, + { + name: "empty field name", + options: ResolveOptions{ + Fields: []FieldSpec{{Name: " "}}, + Lookup: StaticLookup{}.Lookup, + }, + }, + { + name: "duplicate field names", + options: ResolveOptions{ + Fields: []FieldSpec{{Name: "base_url"}, {Name: "base_url"}}, + Lookup: StaticLookup{}.Lookup, + }, + }, + { + name: "unknown source in order", + options: ResolveOptions{ + Fields: []FieldSpec{{Name: "base_url"}}, + Order: []ValueSource{"kv"}, + Lookup: StaticLookup{}.Lookup, + }, + }, + { + name: "default source forbidden in order", + options: ResolveOptions{ + Fields: []FieldSpec{{Name: "base_url"}}, + Order: []ValueSource{SourceDefault}, + Lookup: StaticLookup{}.Lookup, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := ResolveFields(tc.options) + if !errors.Is(err, ErrInvalidResolverInput) { + t.Fatalf("ResolveFields error = %v, want ErrInvalidResolverInput", err) + } + }) + } +} + +func TestRenderResolutionProvenance(t *testing.T) { + var out bytes.Buffer + err := RenderResolutionProvenance(&out, Resolution{ + Fields: []ResolvedField{ + {Name: "base_url", Source: SourceFlag, Found: true}, + {Name: "api_token", Found: false}, + }, + }) + if err != nil { + t.Fatalf("RenderResolutionProvenance returned error: %v", err) + } + + text := out.String() + if !strings.Contains(text, "base_url: flag") { + t.Fatalf("output = %q, want base_url provenance", text) + } + if !strings.Contains(text, "api_token: missing") { + t.Fatalf("output = %q, want missing provenance", text) + } +} + -- 2.45.2 From 34abd29bac838a10f3f29cf27ea7de4d41250678 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 09:17:45 +0200 Subject: [PATCH 05/79] feat(cli): add declarative typed setup engine --- README.md | 52 +++++ cli/setup.go | 530 ++++++++++++++++++++++++++++++++++++++++++++++ cli/setup_test.go | 265 +++++++++++++++++++++++ 3 files changed, 847 insertions(+) create mode 100644 cli/setup.go create mode 100644 cli/setup_test.go diff --git a/README.md b/README.md index 51c79cd..a935a50 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,58 @@ if err != nil { } ``` +Pour décrire un setup complet sans réécrire la boucle interactive : + +```go +result, err := cli.RunSetup(cli.SetupOptions{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Fields: []cli.SetupField{ + { + Name: "base_url", + Label: "Base URL", + Type: cli.SetupFieldURL, + Required: true, + }, + { + Name: "api_token", + Label: "API token", + Type: cli.SetupFieldSecret, + Required: true, + ExistingSecret: storedToken, // conserve la valeur existante si l'utilisateur laisse vide + }, + { + Name: "enabled", + Label: "Enable integration", + Type: cli.SetupFieldBool, + Default: "true", + }, + { + Name: "scopes", + Label: "Scopes", + Type: cli.SetupFieldList, + Default: "read,write", + Normalize: func(value string) string { return strings.TrimSpace(strings.ToLower(value)) }, + }, + }, +}) +if err != nil { + return err +} + +baseURL, _ := result.Get("base_url") +apiToken, _ := result.Get("api_token") +enabled, _ := result.Get("enabled") +scopes, _ := result.Get("scopes") + +if apiToken.KeptStoredSecret { + fmt.Println("Stored token kept.") +} +``` + +Chaque champ peut déclarer ses propres hooks de validation (`Validate`, `ValidateBool`, `ValidateList`). +Les validations sont appliquées de manière cohérente en TTY et en stdin non interactif. + Pour standardiser la résolution `flag > env > config > secret` avec provenance : ```go diff --git a/cli/setup.go b/cli/setup.go new file mode 100644 index 0000000..77eb8bb --- /dev/null +++ b/cli/setup.go @@ -0,0 +1,530 @@ +package cli + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "slices" + "strings" + + "golang.org/x/term" +) + +type SetupFieldType string + +const ( + SetupFieldString SetupFieldType = "string" + SetupFieldURL SetupFieldType = "url" + SetupFieldSecret SetupFieldType = "secret" + SetupFieldBool SetupFieldType = "bool" + SetupFieldList SetupFieldType = "list" +) + +var ErrInvalidSetupDefinition = errors.New("invalid setup definition") + +type SetupField struct { + Name string + Label string + Type SetupFieldType + Required bool + Default string + ExistingSecret string + ListSeparator string + Normalize func(string) string + Validate func(string) error + ValidateBool func(bool) error + ValidateList func([]string) error +} + +type SetupOptions struct { + Fields []SetupField + Stdin *os.File + Stdout io.Writer +} + +type SetupValue struct { + Type SetupFieldType + String string + Bool bool + List []string + Set bool + KeptStoredSecret bool +} + +type SetupResultField struct { + Name string + Value SetupValue +} + +type SetupResult struct { + Fields []SetupResultField +} + +func (r SetupResult) Get(name string) (SetupValue, bool) { + needle := strings.TrimSpace(name) + for _, field := range r.Fields { + if field.Name == needle { + return field.Value, true + } + } + + return SetupValue{}, false +} + +type SetupValidationError struct { + Field string + Label string + Message string +} + +func (e *SetupValidationError) Error() string { + label := strings.TrimSpace(e.Label) + if label == "" { + label = strings.TrimSpace(e.Field) + } + + if label == "" { + return strings.TrimSpace(e.Message) + } + + return fmt.Sprintf("%s: %s", label, strings.TrimSpace(e.Message)) +} + +type normalizedSetupField struct { + Name string + Label string + Type SetupFieldType + Required bool + DefaultString string + DefaultBool *bool + DefaultList []string + ExistingSecret string + ListSeparator string + Normalize func(string) string + Validate func(string) error + ValidateBool func(bool) error + ValidateList func([]string) error +} + +func RunSetup(options SetupOptions) (SetupResult, error) { + stdin, stdout := normalizeSetupIO(options) + + fields, err := normalizeSetupFields(options.Fields) + if err != nil { + return SetupResult{}, err + } + + reader := bufio.NewReader(stdin) + fd := int(stdin.Fd()) + isTTY := term.IsTerminal(fd) + + result := SetupResult{ + Fields: make([]SetupResultField, 0, len(fields)), + } + + for _, field := range fields { + value, err := promptSetupField(reader, stdin, stdout, fd, isTTY, field) + if err != nil { + return result, err + } + + result.Fields = append(result.Fields, SetupResultField{ + Name: field.Name, + Value: value, + }) + } + + return result, nil +} + +func normalizeSetupIO(options SetupOptions) (*os.File, io.Writer) { + stdin := options.Stdin + if stdin == nil { + stdin = os.Stdin + } + + stdout := options.Stdout + if stdout == nil { + stdout = os.Stdout + } + + return stdin, stdout +} + +func normalizeSetupFields(fields []SetupField) ([]normalizedSetupField, error) { + normalized := make([]normalizedSetupField, 0, len(fields)) + seenNames := make(map[string]struct{}, len(fields)) + + for i, field := range fields { + name := strings.TrimSpace(field.Name) + if name == "" { + return nil, fmt.Errorf("%w: field at index %d has empty name", ErrInvalidSetupDefinition, i) + } + if _, exists := seenNames[name]; exists { + return nil, fmt.Errorf("%w: duplicate field name %q", ErrInvalidSetupDefinition, name) + } + seenNames[name] = struct{}{} + + if !isKnownSetupFieldType(field.Type) { + return nil, fmt.Errorf("%w: field %q uses unknown type %q", ErrInvalidSetupDefinition, name, field.Type) + } + + label := strings.TrimSpace(field.Label) + if label == "" { + label = name + } + + normalizer := field.Normalize + if normalizer == nil { + normalizer = strings.TrimSpace + } + + listSeparator := field.ListSeparator + if listSeparator == "" { + listSeparator = "," + } + + entry := normalizedSetupField{ + Name: name, + Label: label, + Type: field.Type, + Required: field.Required, + ExistingSecret: strings.TrimSpace(field.ExistingSecret), + ListSeparator: listSeparator, + Normalize: normalizer, + Validate: field.Validate, + ValidateBool: field.ValidateBool, + ValidateList: field.ValidateList, + } + + switch field.Type { + case SetupFieldString, SetupFieldURL, SetupFieldSecret: + entry.DefaultString = normalizer(field.Default) + if field.Type == SetupFieldURL && entry.DefaultString != "" { + if err := ValidateBaseURL(entry.DefaultString); err != nil { + return nil, fmt.Errorf("%w: field %q default URL is invalid: %v", ErrInvalidSetupDefinition, name, err) + } + } + case SetupFieldBool: + defaultRaw := strings.TrimSpace(field.Default) + if defaultRaw != "" { + defaultValue, err := parseBoolValue(defaultRaw) + if err != nil { + return nil, fmt.Errorf("%w: field %q default bool is invalid: %v", ErrInvalidSetupDefinition, name, err) + } + entry.DefaultBool = &defaultValue + } + case SetupFieldList: + defaultRaw := strings.TrimSpace(field.Default) + if defaultRaw != "" { + entry.DefaultList = splitSetupList(defaultRaw, listSeparator, normalizer) + } + } + + normalized = append(normalized, entry) + } + + return normalized, nil +} + +func promptSetupField( + reader *bufio.Reader, + stdin *os.File, + stdout io.Writer, + fd int, + isTTY bool, + field normalizedSetupField, +) (SetupValue, error) { + for { + if err := renderSetupPrompt(stdout, field); err != nil { + return SetupValue{}, err + } + + raw, err := readSetupInput(reader, stdout, fd, isTTY, field.Type) + if err != nil { + return SetupValue{}, fmt.Errorf("read %q: %w", field.Name, err) + } + + value, validationErr := parseSetupValue(field, raw) + if validationErr == nil { + return value, nil + } + + setupErr := &SetupValidationError{ + Field: field.Name, + Label: field.Label, + Message: validationErr.Error(), + } + if !isTTY { + return SetupValue{}, setupErr + } + + if _, err := fmt.Fprintf(stdout, "Invalid value for %s: %s\n", field.Label, validationErr.Error()); err != nil { + return SetupValue{}, err + } + } +} + +func readSetupInput( + reader *bufio.Reader, + stdout io.Writer, + fd int, + isTTY bool, + fieldType SetupFieldType, +) (string, error) { + if fieldType == SetupFieldSecret && isTTY { + secret, err := term.ReadPassword(fd) + fmt.Fprintln(stdout) + if err != nil { + return "", err + } + return string(secret), nil + } + + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + return line, nil +} + +func parseSetupValue(field normalizedSetupField, raw string) (SetupValue, error) { + switch field.Type { + case SetupFieldString: + return parseSetupStringValue(field, raw) + case SetupFieldURL: + value, err := parseSetupStringValue(field, raw) + if err != nil { + return SetupValue{}, err + } + if value.Set { + if err := ValidateBaseURL(value.String); err != nil { + return SetupValue{}, fmt.Errorf("must be a valid URL with scheme and host") + } + } + return value, nil + case SetupFieldSecret: + return parseSetupSecretValue(field, raw) + case SetupFieldBool: + return parseSetupBoolValue(field, raw) + case SetupFieldList: + return parseSetupListValue(field, raw) + default: + return SetupValue{}, fmt.Errorf("unsupported field type %q", field.Type) + } +} + +func parseSetupStringValue(field normalizedSetupField, raw string) (SetupValue, error) { + value := field.Normalize(raw) + set := value != "" + if !set && field.DefaultString != "" { + value = field.DefaultString + set = true + } + + if field.Required && !set { + return SetupValue{}, fmt.Errorf("value is required") + } + + if set && field.Validate != nil { + if err := field.Validate(value); err != nil { + return SetupValue{}, err + } + } + + return SetupValue{ + Type: field.Type, + String: value, + Set: set, + }, nil +} + +func parseSetupSecretValue(field normalizedSetupField, raw string) (SetupValue, error) { + value := field.Normalize(raw) + set := value != "" + keptStored := false + + if !set && field.ExistingSecret != "" { + value = field.ExistingSecret + set = true + keptStored = true + } else if !set && field.DefaultString != "" { + value = field.DefaultString + set = true + } + + if field.Required && !set { + return SetupValue{}, fmt.Errorf("value is required") + } + + if set && field.Validate != nil { + if err := field.Validate(value); err != nil { + return SetupValue{}, err + } + } + + return SetupValue{ + Type: field.Type, + String: value, + Set: set, + KeptStoredSecret: keptStored, + }, nil +} + +func parseSetupBoolValue(field normalizedSetupField, raw string) (SetupValue, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + if field.DefaultBool != nil { + value := *field.DefaultBool + if field.ValidateBool != nil { + if err := field.ValidateBool(value); err != nil { + return SetupValue{}, err + } + } + return SetupValue{ + Type: SetupFieldBool, + Bool: value, + Set: true, + }, nil + } + + if field.Required { + return SetupValue{}, fmt.Errorf("value is required") + } + + return SetupValue{ + Type: SetupFieldBool, + Set: false, + }, nil + } + + value, err := parseBoolValue(trimmed) + if err != nil { + return SetupValue{}, err + } + if field.ValidateBool != nil { + if err := field.ValidateBool(value); err != nil { + return SetupValue{}, err + } + } + + return SetupValue{ + Type: SetupFieldBool, + Bool: value, + Set: true, + }, nil +} + +func parseSetupListValue(field normalizedSetupField, raw string) (SetupValue, error) { + trimmed := strings.TrimSpace(raw) + + var list []string + set := false + if trimmed != "" { + list = splitSetupList(trimmed, field.ListSeparator, field.Normalize) + set = true + } else if len(field.DefaultList) > 0 { + list = slices.Clone(field.DefaultList) + set = true + } + + if field.Required && len(list) == 0 { + return SetupValue{}, fmt.Errorf("value is required") + } + + if field.ValidateList != nil { + if err := field.ValidateList(list); err != nil { + return SetupValue{}, err + } + } + + return SetupValue{ + Type: SetupFieldList, + List: list, + Set: set, + }, nil +} + +func renderSetupPrompt(w io.Writer, field normalizedSetupField) error { + switch field.Type { + case SetupFieldSecret: + if field.ExistingSecret != "" { + _, err := fmt.Fprintf(w, "%s [stored, leave blank to keep]: ", field.Label) + return err + } + if field.DefaultString != "" { + _, err := fmt.Fprintf(w, "%s [%s]: ", field.Label, field.DefaultString) + return err + } + _, err := fmt.Fprintf(w, "%s: ", field.Label) + return err + case SetupFieldBool: + defaultLabel := "y/n" + if field.DefaultBool != nil { + if *field.DefaultBool { + defaultLabel = "Y/n" + } else { + defaultLabel = "y/N" + } + } + _, err := fmt.Fprintf(w, "%s [%s]: ", field.Label, defaultLabel) + return err + case SetupFieldList: + if len(field.DefaultList) > 0 { + _, err := fmt.Fprintf( + w, + "%s [%s]: ", + field.Label, + strings.Join(field.DefaultList, field.ListSeparator), + ) + return err + } + _, err := fmt.Fprintf(w, "%s: ", field.Label) + return err + case SetupFieldString, SetupFieldURL: + if field.DefaultString != "" { + _, err := fmt.Fprintf(w, "%s [%s]: ", field.Label, field.DefaultString) + return err + } + _, err := fmt.Fprintf(w, "%s: ", field.Label) + return err + default: + _, err := fmt.Fprintf(w, "%s: ", field.Label) + return err + } +} + +func splitSetupList(raw, separator string, normalize func(string) string) []string { + parts := strings.Split(raw, separator) + list := make([]string, 0, len(parts)) + for _, part := range parts { + normalized := normalize(part) + if normalized == "" { + continue + } + list = append(list, normalized) + } + return list +} + +func parseBoolValue(raw string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "t", "true", "y", "yes", "on": + return true, nil + case "0", "f", "false", "n", "no", "off": + return false, nil + default: + return false, fmt.Errorf("must be one of: yes/no, y/n, true/false, 1/0") + } +} + +func isKnownSetupFieldType(fieldType SetupFieldType) bool { + switch fieldType { + case SetupFieldString, SetupFieldURL, SetupFieldSecret, SetupFieldBool, SetupFieldList: + return true + default: + return false + } +} diff --git a/cli/setup_test.go b/cli/setup_test.go new file mode 100644 index 0000000..922079c --- /dev/null +++ b/cli/setup_test.go @@ -0,0 +1,265 @@ +package cli + +import ( + "bytes" + "errors" + "os" + "slices" + "strings" + "testing" +) + +func TestRunSetupParsesTypedFieldsWithDefaultsAndNormalization(t *testing.T) { + stdin := setupTestInputFile(t, strings.Join([]string{ + " https://api.example.com ", + "", + "", + "Read, WRITE, admin", + " eu-west ", + }, "\n")+"\n") + + var stdout bytes.Buffer + result, err := RunSetup(SetupOptions{ + Stdin: stdin, + Stdout: &stdout, + Fields: []SetupField{ + { + Name: "base_url", + Label: "Base URL", + Type: SetupFieldURL, + Required: true, + }, + { + Name: "api_token", + Label: "API token", + Type: SetupFieldSecret, + Required: true, + ExistingSecret: "stored-token", + }, + { + Name: "enabled", + Label: "Enabled", + Type: SetupFieldBool, + Default: "true", + }, + { + Name: "scopes", + Label: "Scopes", + Type: SetupFieldList, + Default: "read,write", + Normalize: func(raw string) string { + return strings.ToLower(strings.TrimSpace(raw)) + }, + }, + { + Name: "region", + Label: "Region", + Type: SetupFieldString, + Default: "eu-central", + Normalize: strings.TrimSpace, + }, + }, + }) + if err != nil { + t.Fatalf("RunSetup returned error: %v", err) + } + + baseURL, ok := result.Get("base_url") + if !ok { + t.Fatalf("missing base_url field") + } + if !baseURL.Set || baseURL.String != "https://api.example.com" { + t.Fatalf("base_url = %#v, want normalized URL", baseURL) + } + + token, ok := result.Get("api_token") + if !ok { + t.Fatalf("missing api_token field") + } + if token.String != "stored-token" { + t.Fatalf("token.String = %q, want stored-token", token.String) + } + if !token.KeptStoredSecret { + t.Fatalf("token should keep stored secret when blank") + } + + enabled, ok := result.Get("enabled") + if !ok { + t.Fatalf("missing enabled field") + } + if !enabled.Bool || !enabled.Set { + t.Fatalf("enabled = %#v, want true from default", enabled) + } + + scopes, ok := result.Get("scopes") + if !ok { + t.Fatalf("missing scopes field") + } + wantScopes := []string{"read", "write", "admin"} + if !slices.Equal(scopes.List, wantScopes) { + t.Fatalf("scopes.List = %v, want %v", scopes.List, wantScopes) + } + + region, ok := result.Get("region") + if !ok { + t.Fatalf("missing region field") + } + if region.String != "eu-west" { + t.Fatalf("region.String = %q, want eu-west", region.String) + } + + promptOutput := stdout.String() + for _, needle := range []string{ + "Base URL:", + "API token [stored, leave blank to keep]:", + "Enabled [Y/n]:", + "Scopes [read,write]:", + "Region [eu-central]:", + } { + if !strings.Contains(promptOutput, needle) { + t.Fatalf("prompt output = %q, want substring %q", promptOutput, needle) + } + } +} + +func TestRunSetupReturnsReadableValidationErrorInNonInteractiveMode(t *testing.T) { + stdin := setupTestInputFile(t, "maybe\n") + var stdout bytes.Buffer + + _, err := RunSetup(SetupOptions{ + Stdin: stdin, + Stdout: &stdout, + Fields: []SetupField{ + { + Name: "enabled", + Label: "Enabled", + Type: SetupFieldBool, + }, + }, + }) + + var validationErr *SetupValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("RunSetup error = %v, want SetupValidationError", err) + } + if validationErr.Field != "enabled" { + t.Fatalf("validationErr.Field = %q, want enabled", validationErr.Field) + } + if !strings.Contains(validationErr.Error(), "must be one of") { + t.Fatalf("validationErr = %v, want readable bool error", validationErr) + } +} + +func TestRunSetupSupportsValidationHooks(t *testing.T) { + stdin := setupTestInputFile(t, "https://example.com\nalpha,beta\n") + + _, err := RunSetup(SetupOptions{ + Stdin: stdin, + Fields: []SetupField{ + { + Name: "base_url", + Label: "Base URL", + Type: SetupFieldURL, + Validate: func(value string) error { + if !strings.HasSuffix(value, "/v1") { + return errors.New("must end with /v1") + } + return nil + }, + }, + { + Name: "scopes", + Type: SetupFieldList, + ValidateList: func(values []string) error { + if len(values) < 3 { + return errors.New("at least 3 scopes are required") + } + return nil + }, + }, + }, + }) + + var validationErr *SetupValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("RunSetup error = %v, want SetupValidationError", err) + } + if validationErr.Field != "base_url" { + t.Fatalf("validationErr.Field = %q, want base_url", validationErr.Field) + } + if !strings.Contains(validationErr.Error(), "must end with /v1") { + t.Fatalf("validationErr = %v", validationErr) + } +} + +func TestRunSetupRejectsInvalidDefinitions(t *testing.T) { + tests := []struct { + name string + fields []SetupField + }{ + { + name: "empty field name", + fields: []SetupField{ + {Name: " ", Type: SetupFieldString}, + }, + }, + { + name: "duplicate field names", + fields: []SetupField{ + {Name: "base_url", Type: SetupFieldString}, + {Name: "base_url", Type: SetupFieldURL}, + }, + }, + { + name: "unknown type", + fields: []SetupField{ + {Name: "base_url", Type: SetupFieldType("json")}, + }, + }, + { + name: "invalid bool default", + fields: []SetupField{ + {Name: "enabled", Type: SetupFieldBool, Default: "sometimes"}, + }, + }, + { + name: "invalid url default", + fields: []SetupField{ + {Name: "base_url", Type: SetupFieldURL, Default: "localhost"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := RunSetup(SetupOptions{ + Stdin: setupTestInputFile(t, ""), + Fields: tc.fields, + }) + if !errors.Is(err, ErrInvalidSetupDefinition) { + t.Fatalf("RunSetup error = %v, want ErrInvalidSetupDefinition", err) + } + }) + } +} + +func setupTestInputFile(t *testing.T, content string) *os.File { + t.Helper() + + file, err := os.CreateTemp(t.TempDir(), "setup-input-*.txt") + if err != nil { + t.Fatalf("CreateTemp returned error: %v", err) + } + t.Cleanup(func() { + _ = file.Close() + }) + + if _, err := file.WriteString(content); err != nil { + t.Fatalf("WriteString returned error: %v", err) + } + if _, err := file.Seek(0, 0); err != nil { + t.Fatalf("Seek returned error: %v", err) + } + + return file +} -- 2.45.2 From 1c40546f3f0b16e91328936c3eea71612fc78364 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 10:28:15 +0200 Subject: [PATCH 06/79] fix(ci): build changelog from last stable release tag --- .gitea/workflows/release.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e67325a..51d46b6 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -26,13 +26,19 @@ jobs: set -euo pipefail current_tag="${GITHUB_REF_NAME}" - previous_tag="" + previous_stable_tag="" - if previous_tag="$(git describe --tags --abbrev=0 "${current_tag}^" 2>/dev/null)"; then - range="${previous_tag}..${current_tag}" + if previous_stable_tag="$( + git describe --tags --abbrev=0 \ + --exclude '*-rc*' \ + --exclude '*-beta*' \ + --exclude '*-alpha*' \ + "${current_tag}^" 2>/dev/null + )"; then + range="${previous_stable_tag}..${current_tag}" { printf '## Changelog\n\n' - printf 'Changes since `%s`.\n\n' "${previous_tag}" + printf 'Changes since `%s`.\n\n' "${previous_stable_tag}" git log --reverse --pretty=format:'- %h %s' "${range}" printf '\n' } >CHANGELOG.md -- 2.45.2 From 89246d1581a13477f5b9fd105a92f16bebb84e89 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 10:52:36 +0200 Subject: [PATCH 07/79] feat(bootstrap): standardize config show/test command structure --- README.md | 12 ++- bootstrap/bootstrap.go | 164 +++++++++++++++++++++++++++++++++--- bootstrap/bootstrap_test.go | 140 +++++++++++++++++++++++++++++- 3 files changed, 298 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a935a50..501a9d2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ go get gitea.lclr.dev/AI/mcp-framework ## Packages -- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config`, `update`, `version`) et hooks métier explicites. +- `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`. @@ -58,8 +58,11 @@ func main() { MCP: func(ctx context.Context, inv bootstrap.Invocation) error { return runMCP(ctx, inv.Args) }, - Config: func(ctx context.Context, inv bootstrap.Invocation) error { - return runConfig(ctx, inv.Args) + ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error { + return runConfigShow(ctx, inv.Args) + }, + ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error { + return runConfigTest(ctx, inv.Args) }, Update: func(ctx context.Context, inv bootstrap.Invocation) error { return runUpdate(ctx, inv.Args) @@ -75,6 +78,9 @@ func main() { Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`. +Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). +La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). + ## Manifeste `mcp.toml` Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 7e4854d..7108942 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -15,23 +15,32 @@ const ( CommandConfig = "config" CommandUpdate = "update" CommandVersion = "version" + + ConfigSubcommandShow = "show" + ConfigSubcommandTest = "test" + ConfigSubcommandDelete = "delete" ) var ( ErrBinaryNameRequired = errors.New("binary name is required") ErrUnknownCommand = errors.New("unknown command") + ErrUnknownSubcommand = errors.New("unknown subcommand") ErrCommandNotConfigured = errors.New("command not configured") ErrVersionRequired = errors.New("version is required when no version hook is configured") + ErrSubcommandRequired = errors.New("subcommand is required") ) type Handler func(context.Context, Invocation) error type Hooks struct { - Setup Handler - MCP Handler - Config Handler - Update Handler - Version Handler + Setup Handler + MCP Handler + Config Handler + ConfigShow Handler + ConfigTest Handler + ConfigDelete Handler + Update Handler + Version Handler } type Options struct { @@ -106,13 +115,17 @@ func Run(ctx context.Context, opts Options) error { command, commandArgs, showHelp := parseArgs(normalized.Args) if showHelp { - return printHelp(normalized, command) + return printHelp(normalized, command, commandArgs) } if command == "" { return printHelp(normalized, "") } + if command == CommandConfig { + return runConfigCommand(ctx, normalized, commandArgs) + } + handler, known := resolveHandler(command, normalized.Hooks) if !known { return fmt.Errorf("%w: %s", ErrUnknownCommand, command) @@ -163,23 +176,96 @@ func parseArgs(args []string) (command string, commandArgs []string, showHelp bo switch first { case "help", "-h", "--help": if len(args) > 1 { - return strings.TrimSpace(args[1]), nil, true + return strings.TrimSpace(args[1]), trimArgs(args[2:]), true } return "", nil, true } command = first - commandArgs = args[1:] - if len(commandArgs) == 1 { - helpArg := strings.TrimSpace(commandArgs[0]) - if helpArg == "-h" || helpArg == "--help" { - return command, nil, true + commandArgs = trimArgs(args[1:]) + if len(commandArgs) > 0 { + last := strings.TrimSpace(commandArgs[len(commandArgs)-1]) + if last == "-h" || last == "--help" { + return command, commandArgs[:len(commandArgs)-1], true } } return command, commandArgs, false } +func trimArgs(args []string) []string { + if len(args) == 0 { + return nil + } + + result := make([]string, 0, len(args)) + for _, arg := range args { + trimmed := strings.TrimSpace(arg) + if trimmed == "" { + continue + } + result = append(result, trimmed) + } + return result +} + +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) + } + + subcommand := strings.TrimSpace(args[0]) + subcommandArgs := args[1:] + + handler, known := resolveConfigHandler(opts.Hooks, subcommand) + if !known { + if opts.Hooks.Config != nil { + return opts.Hooks.Config(ctx, Invocation{ + Command: CommandConfig, + Args: args, + Stdin: opts.Stdin, + Stdout: opts.Stdout, + Stderr: opts.Stderr, + }) + } + return fmt.Errorf("%w: %s %s", ErrUnknownSubcommand, CommandConfig, subcommand) + } + + if handler == nil { + if opts.Hooks.Config != nil { + return opts.Hooks.Config(ctx, Invocation{ + Command: fmt.Sprintf("%s %s", CommandConfig, subcommand), + Args: subcommandArgs, + Stdin: opts.Stdin, + Stdout: opts.Stdout, + Stderr: opts.Stderr, + }) + } + return fmt.Errorf("%w: %s %s", ErrCommandNotConfigured, CommandConfig, subcommand) + } + + return handler(ctx, Invocation{ + Command: fmt.Sprintf("%s %s", CommandConfig, subcommand), + Args: subcommandArgs, + Stdin: opts.Stdin, + Stdout: opts.Stdout, + Stderr: opts.Stderr, + }) +} + +func resolveConfigHandler(hooks Hooks, subcommand string) (Handler, bool) { + switch subcommand { + case ConfigSubcommandShow: + return hooks.ConfigShow, true + case ConfigSubcommandTest: + return hooks.ConfigTest, true + case ConfigSubcommandDelete: + return hooks.ConfigDelete, true + default: + return nil, false + } +} + func resolveHandler(command string, hooks Hooks) (Handler, bool) { for _, def := range commands { if def.Name == command { @@ -189,11 +275,20 @@ func resolveHandler(command string, hooks Hooks) (Handler, bool) { return nil, false } -func printHelp(opts Options, command string) error { +func printHelp(opts Options, command string, args ...[]string) error { + var commandArgs []string + if len(args) > 0 { + commandArgs = args[0] + } + if command == "" { return printGlobalHelp(opts) } + if command == CommandConfig { + return printConfigHelp(opts, commandArgs) + } + for _, def := range commands { if def.Name != command { continue @@ -211,6 +306,49 @@ func printHelp(opts Options, command string) error { return fmt.Errorf("%w: %s", ErrUnknownCommand, command) } +func printConfigHelp(opts Options, args []string) error { + if len(args) == 0 { + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s config [args]\n\nSubcommandes:\n %-7s Afficher la configuration résolue et la provenance des valeurs.\n %-7s Vérifier la configuration et la connectivité.\n %-7s Supprimer un profil local (optionnel).\n", + opts.BinaryName, + ConfigSubcommandShow, + ConfigSubcommandTest, + ConfigSubcommandDelete, + ) + return err + } + + switch args[0] { + case ConfigSubcommandShow: + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s config %s [args]\n\nAfficher la configuration résolue et l'origine des valeurs.\n", + opts.BinaryName, + ConfigSubcommandShow, + ) + return err + case ConfigSubcommandTest: + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s config %s [args]\n\nTester la configuration résolue et la connectivité associée.\n", + opts.BinaryName, + ConfigSubcommandTest, + ) + return err + case ConfigSubcommandDelete: + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s config %s [args]\n\nSupprimer un profil local de configuration.\n", + opts.BinaryName, + ConfigSubcommandDelete, + ) + return err + default: + return fmt.Errorf("%w: %s %s", ErrUnknownSubcommand, CommandConfig, args[0]) + } +} + func printGlobalHelp(opts Options) error { if strings.TrimSpace(opts.Description) != "" { if _, err := fmt.Fprintf(opts.Stdout, "%s\n\n", opts.Description); err != nil { diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index b0c6b4d..29031ab 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -65,8 +65,8 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) { Stdout: &stdout, Stderr: &stderr, }) - if !errors.Is(err, ErrCommandNotConfigured) { - t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err) + if !errors.Is(err, ErrSubcommandRequired) { + t.Fatalf("Run error = %v, want ErrSubcommandRequired", err) } } @@ -158,6 +158,36 @@ func TestRunPrintsGlobalHelp(t *testing.T) { } } +func TestRunPrintsGlobalHelpWhenNoArgs(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Description: "Binaire MCP de test.", + Args: []string{}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + for _, snippet := range []string{ + "Usage:", + "setup", + "mcp", + "config", + "update", + "version", + } { + if !strings.Contains(text, snippet) { + t.Fatalf("help output missing %q: %s", snippet, text) + } + } +} + func TestRunPrintsCommandHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -180,3 +210,109 @@ func TestRunPrintsCommandHelp(t *testing.T) { t.Fatalf("command help output missing update description: %q", text) } } + +func TestRunPrintsConfigHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"help", "config"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + for _, snippet := range []string{ + "my-mcp config ", + "show", + "test", + } { + if !strings.Contains(text, snippet) { + t.Fatalf("config help output missing %q: %q", snippet, text) + } + } +} + +func TestRunPrintsConfigSubcommandHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"help", "config", "show"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp config show [args]") { + t.Fatalf("config subcommand help output = %q", text) + } +} + +func TestRunRoutesConfigShowHook(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + var got Invocation + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config", "show", "--profile", "prod"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + ConfigShow: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != "config show" { + t.Fatalf("invocation command = %q, want %q", got.Command, "config show") + } + wantArgs := []string{"--profile", "prod"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } +} + +func TestRunConfigShowReturnsCommandNotConfigured(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config", "show"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrCommandNotConfigured) { + t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err) + } +} + +func TestRunConfigReturnsUnknownSubcommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config", "sync"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrUnknownSubcommand) { + t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err) + } +} -- 2.45.2 From 17fe329ce982aa498ca60804e185ec59fef79013 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 12:30:56 +0200 Subject: [PATCH 08/79] feat(manifest): extend mcp.toml metadata sections --- README.md | 36 +++++++++++-- manifest/manifest.go | 107 +++++++++++++++++++++++++++++++++++--- manifest/manifest_test.go | 80 ++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 11 deletions(-) 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/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) + } +} -- 2.45.2 From cae689d0e4415367a6f734a8281b0231452d7e26 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 13:55:00 +0200 Subject: [PATCH 09/79] fix(bootstrap): include delete in config subcommand hint --- bootstrap/bootstrap.go | 9 ++++++++- bootstrap/bootstrap_test.go | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) 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) { -- 2.45.2 From bf8e1285d874dc4746825d631093c725071c9842 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 14:11:43 +0200 Subject: [PATCH 10/79] feat(update): add forge drivers and checksum validation hooks --- README.md | 36 +- manifest/manifest.go | 37 +- manifest/manifest_test.go | 24 ++ update/update.go | 551 +++++++++++++++++++++++++++--- update/update_test.go | 693 +++++++++++++++++++++++++++++++++++++- 5 files changed, 1267 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 119084d..ce2a5be 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,14 @@ docs_url = "https://docs.example.com/my-mcp" [update] source_name = "Gitea releases" +driver = "gitea" +repository = "org/repo" base_url = "https://gitea.example.com" -latest_release_url = "https://gitea.example.com/api/v1/repos/org/repo/releases/latest" +asset_name_template = "{binary}-{os}-{arch}{ext}" +checksum_asset_name = "{asset}.sha256" +checksum_required = false token_header = "Authorization" +token_prefix = "token" token_env_names = ["GITEA_TOKEN"] [environment] @@ -120,9 +125,15 @@ Champs supportés : - `[update]` : source de release consommée par `update`. - `source_name` : nom humain de la source de release, utilisé dans certains messages d'erreur. +- `driver` : driver de forge (`gitea`, `gitlab`, `github`) pour déduire automatiquement l'endpoint latest. +- `repository` : dépôt cible (`org/repo` ou `group/subgroup/repo`). - `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. +- `latest_release_url` : URL complète qui retourne la release la plus récente (prioritaire sur le driver). +- `asset_name_template` : template de nom d'asset (`{binary}`, `{os}`, `{arch}`, `{ext}`). +- `checksum_asset_name` : nom d'asset checksum, avec placeholder optionnel `{asset}`. +- `checksum_required` : si `true`, l'update échoue quand l'asset checksum est absent. - `token_header` : header HTTP à utiliser pour l'authentification. +- `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...). - `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`). @@ -462,9 +473,14 @@ if report.HasFailures() { ## Auto-Update -Le package `update` ne déduit pas la forge ni l'authentification. -L'application cliente fournit l'URL de release, le header d'auth éventuel et, -si besoin, les variables d'environnement à consulter. +Le package `update` supporte les drivers `gitea`, `gitlab` et `github`. +Si `latest_release_url` est vide, l'URL latest est déduite depuis +`driver + repository (+ base_url)`. + +Le parseur de release supporte : + +- format `assets.links` (Gitea/GitLab) +- format `assets[]` avec `browser_download_url` (GitHub et Gitea API) Le format attendu pour la réponse `latest release` est actuellement : @@ -501,12 +517,12 @@ if err != nil { } ``` -Contraintes actuelles : +Comportement : -- le `latest_release_url` doit être renseigné explicitement -- les assets supportés sont `darwin/amd64`, `darwin/arm64`, `linux/amd64` et `windows/amd64` -- le remplacement du binaire n'est pas supporté sur Windows -- le nom de l'asset est dérivé de `BinaryName`, `GOOS` et `GOARCH` +- le nom de l'asset est configurable (`asset_name_template`) et supporte tout couple `GOOS/GOARCH` +- si un asset `.sha256` (ou `checksum_asset_name`) existe, le binaire téléchargé est vérifié avant remplacement +- un hook `ValidateDownloaded` permet d'ajouter une validation custom (signature, scan, etc.) +- sur Windows, le remplacement in-place n'est pas fait par défaut ; fournir `Options.ReplaceExecutable` pour une stratégie dédiée ## Exemple Minimal diff --git a/manifest/manifest.go b/manifest/manifest.go index 21c0bcd..b3c9414 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -25,11 +25,17 @@ type File struct { } type Update struct { - SourceName string `toml:"source_name"` - BaseURL string `toml:"base_url"` - LatestReleaseURL string `toml:"latest_release_url"` - TokenHeader string `toml:"token_header"` - TokenEnvNames []string `toml:"token_env_names"` + SourceName string `toml:"source_name"` + Driver string `toml:"driver"` + Repository string `toml:"repository"` + BaseURL string `toml:"base_url"` + LatestReleaseURL string `toml:"latest_release_url"` + AssetNameTemplate string `toml:"asset_name_template"` + ChecksumAssetName string `toml:"checksum_asset_name"` + ChecksumRequired bool `toml:"checksum_required"` + TokenHeader string `toml:"token_header"` + TokenPrefix string `toml:"token_prefix"` + TokenEnvNames []string `toml:"token_env_names"` } type Environment struct { @@ -143,9 +149,14 @@ func (f *File) normalize() { func (u *Update) normalize() { u.SourceName = strings.TrimSpace(u.SourceName) + u.Driver = strings.ToLower(strings.TrimSpace(u.Driver)) + u.Repository = strings.Trim(strings.TrimSpace(u.Repository), "/") u.BaseURL = strings.TrimRight(strings.TrimSpace(u.BaseURL), "/") u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL) + u.AssetNameTemplate = strings.TrimSpace(u.AssetNameTemplate) + u.ChecksumAssetName = strings.TrimSpace(u.ChecksumAssetName) u.TokenHeader = strings.TrimSpace(u.TokenHeader) + u.TokenPrefix = strings.TrimSpace(u.TokenPrefix) u.TokenEnvNames = normalizeStringList(u.TokenEnvNames) } @@ -170,11 +181,17 @@ func (u Update) ReleaseSource() update.ReleaseSource { u.normalize() return update.ReleaseSource{ - Name: u.SourceName, - BaseURL: u.BaseURL, - LatestReleaseURL: u.LatestReleaseURL, - TokenHeader: u.TokenHeader, - TokenEnvNames: append([]string(nil), u.TokenEnvNames...), + Name: u.SourceName, + Driver: u.Driver, + Repository: u.Repository, + BaseURL: u.BaseURL, + LatestReleaseURL: u.LatestReleaseURL, + AssetNameTemplate: u.AssetNameTemplate, + ChecksumAssetName: u.ChecksumAssetName, + ChecksumRequired: u.ChecksumRequired, + TokenHeader: u.TokenHeader, + TokenPrefix: u.TokenPrefix, + TokenEnvNames: append([]string(nil), u.TokenEnvNames...), } } diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index ebc9454..83ea7d5 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -46,9 +46,15 @@ func TestLoadParsesUpdateConfig(t *testing.T) { const content = ` [update] source_name = " Gitea releases " +driver = " Gitea " +repository = " org/repo " base_url = "https://gitea.example.com/" latest_release_url = "https://gitea.example.com/api/releases/latest" +asset_name_template = "{binary}_{os}_{arch}{ext}" +checksum_asset_name = "{asset}.sha256" +checksum_required = true token_header = " Authorization " +token_prefix = " token " token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"] ` @@ -65,15 +71,33 @@ token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"] if source.Name != "Gitea releases" { t.Fatalf("source name = %q", source.Name) } + if source.Driver != "gitea" { + t.Fatalf("driver = %q", source.Driver) + } + if source.Repository != "org/repo" { + t.Fatalf("repository = %q", source.Repository) + } if source.BaseURL != "https://gitea.example.com" { t.Fatalf("base URL = %q", source.BaseURL) } if source.LatestReleaseURL != "https://gitea.example.com/api/releases/latest" { t.Fatalf("latest release URL = %q", source.LatestReleaseURL) } + if source.AssetNameTemplate != "{binary}_{os}_{arch}{ext}" { + t.Fatalf("asset name template = %q", source.AssetNameTemplate) + } + if source.ChecksumAssetName != "{asset}.sha256" { + t.Fatalf("checksum asset name = %q", source.ChecksumAssetName) + } + if !source.ChecksumRequired { + t.Fatal("checksum required should be true") + } if source.TokenHeader != "Authorization" { t.Fatalf("token header = %q", source.TokenHeader) } + if source.TokenPrefix != "token" { + t.Fatalf("token prefix = %q", source.TokenPrefix) + } if len(source.TokenEnvNames) != 2 { t.Fatalf("token env names = %v", source.TokenEnvNames) } diff --git a/update/update.go b/update/update.go index 12593d8..7ba393a 100644 --- a/update/update.go +++ b/update/update.go @@ -2,6 +2,8 @@ package update import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -9,31 +11,57 @@ import ( "net/http" "net/url" "os" + "path" "path/filepath" "runtime" + "sort" "strings" "time" ) +const defaultAssetNameTemplate = "{binary}-{os}-{arch}{ext}" + type Options struct { - Client *http.Client - CurrentVersion string - ExecutablePath string - LatestReleaseURL string - Stdout io.Writer - BinaryName string - ReleaseSource ReleaseSource - GOOS string - GOARCH string + Client *http.Client + CurrentVersion string + ExecutablePath string + LatestReleaseURL string + Stdout io.Writer + BinaryName string + AssetNameTemplate string + ReleaseSource ReleaseSource + GOOS string + GOARCH string + ValidateDownloaded ValidateDownloadedFunc + ReplaceExecutable ReplaceExecutableFunc +} + +type ReplaceExecutableFunc func(downloadPath, targetPath string) error + +type ValidateDownloadedFunc func(context.Context, ValidationInput) error + +type ValidationInput struct { + DownloadPath string + TargetPath string + AssetName string + ReleaseTag string + ReleaseURL string + Source ReleaseSource } type ReleaseSource struct { - Name string - BaseURL string - LatestReleaseURL string - Token string - TokenHeader string - TokenEnvNames []string + Name string + Driver string + Repository string + BaseURL string + LatestReleaseURL string + AssetNameTemplate string + ChecksumAssetName string + ChecksumRequired bool + Token string + TokenHeader string + TokenPrefix string + TokenEnvNames []string } type Auth struct { @@ -53,6 +81,33 @@ type ReleaseLink struct { URL string `json:"url"` } +type releasePayload struct { + TagName string `json:"tag_name"` + Assets json.RawMessage `json:"assets"` +} + +type releaseAssetsPayload struct { + Links []releaseLinkPayload `json:"links"` +} + +type releaseLinkPayload struct { + Name string `json:"name"` + URL string `json:"url"` + BrowserDownloadURL string `json:"browser_download_url"` + DirectAssetURL string `json:"direct_asset_url"` +} + +func (r *Release) UnmarshalJSON(data []byte) error { + var payload releasePayload + if err := json.Unmarshal(data, &payload); err != nil { + return err + } + + r.TagName = strings.TrimSpace(payload.TagName) + r.Assets.Links = parseReleaseLinks(payload.Assets) + return nil +} + func Run(ctx context.Context, opts Options) error { if opts.Stdout == nil { opts.Stdout = io.Discard @@ -73,12 +128,9 @@ func Run(ctx context.Context, opts Options) error { source := normalizeSource(opts.ReleaseSource) auth := ResolveAuth(source.Token, source) - releaseURL := opts.LatestReleaseURL - if strings.TrimSpace(releaseURL) == "" { - releaseURL = strings.TrimSpace(source.LatestReleaseURL) - } - if releaseURL == "" { - return errors.New("latest release URL must not be empty") + releaseURL, err := ResolveLatestReleaseURL(opts.LatestReleaseURL, source) + if err != nil { + return err } targetPath, err := ResolveUpdateTarget(opts.ExecutablePath) @@ -86,7 +138,12 @@ func Run(ctx context.Context, opts Options) error { return err } - assetName, err := AssetName(opts.BinaryName, opts.GOOS, opts.GOARCH) + assetTemplate := strings.TrimSpace(opts.AssetNameTemplate) + if assetTemplate == "" { + assetTemplate = source.AssetNameTemplate + } + + assetName, err := AssetNameWithTemplate(opts.BinaryName, opts.GOOS, opts.GOARCH, assetTemplate) if err != nil { return err } @@ -111,7 +168,28 @@ func Run(ctx context.Context, opts Options) error { } defer os.Remove(downloadPath) - if err := ReplaceExecutable(downloadPath, targetPath); err != nil { + if err := VerifyReleaseAssetChecksum(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil { + return err + } + + if opts.ValidateDownloaded != nil { + if err := opts.ValidateDownloaded(ctx, ValidationInput{ + DownloadPath: downloadPath, + TargetPath: targetPath, + AssetName: assetName, + ReleaseTag: release.TagName, + ReleaseURL: releaseURL, + Source: source, + }); err != nil { + return fmt.Errorf("validate downloaded artifact: %w", err) + } + } + + replaceExecutable := opts.ReplaceExecutable + if replaceExecutable == nil { + replaceExecutable = ReplaceExecutable + } + if err := replaceExecutable(downloadPath, targetPath); err != nil { return err } @@ -119,16 +197,55 @@ func Run(ctx context.Context, opts Options) error { return nil } +func ResolveLatestReleaseURL(explicit string, source ReleaseSource) (string, error) { + if releaseURL := strings.TrimSpace(explicit); releaseURL != "" { + return releaseURL, nil + } + + source = normalizeSource(source) + if source.LatestReleaseURL != "" { + return source.LatestReleaseURL, nil + } + + if source.Driver == "" { + return "", errors.New("latest release URL must not be empty (set latest_release_url or configure driver+repository)") + } + if source.Repository == "" { + return "", fmt.Errorf("release source %q requires repository when driver is set", source.Driver) + } + + switch source.Driver { + case "gitea": + if source.BaseURL == "" { + return "", errors.New("release source gitea requires base_url") + } + return fmt.Sprintf("%s/api/v1/repos/%s/releases/latest", source.BaseURL, source.Repository), nil + case "gitlab": + projectPath := url.PathEscape(source.Repository) + return fmt.Sprintf("%s/api/v4/projects/%s/releases/permalink/latest", source.BaseURL, projectPath), nil + case "github": + return fmt.Sprintf("%s/repos/%s/releases/latest", source.BaseURL, source.Repository), nil + default: + return "", fmt.Errorf("unsupported release driver %q (expected gitea, gitlab or github)", source.Driver) + } +} + func ResolveAuth(explicitToken string, source ReleaseSource) Auth { source = normalizeSource(source) if token := strings.TrimSpace(explicitToken); token != "" { - return Auth{Header: source.TokenHeader, Token: token} + return Auth{ + Header: source.TokenHeader, + Token: withTokenPrefix(token, source.TokenPrefix), + } } for _, envName := range source.TokenEnvNames { if token := strings.TrimSpace(os.Getenv(envName)); token != "" { - return Auth{Header: source.TokenHeader, Token: token} + return Auth{ + Header: source.TokenHeader, + Token: withTokenPrefix(token, source.TokenPrefix), + } } } @@ -153,23 +270,46 @@ func ResolveUpdateTarget(explicitPath string) (string, error) { } func AssetName(binaryName, goos, goarch string) (string, error) { + return AssetNameWithTemplate(binaryName, goos, goarch, defaultAssetNameTemplate) +} + +func AssetNameWithTemplate(binaryName, goos, goarch, template string) (string, error) { name := strings.TrimSpace(binaryName) if name == "" { return "", errors.New("binary name must not be empty") } - switch { - case goos == "darwin" && goarch == "amd64": - return name + "-darwin-amd64", nil - case goos == "darwin" && goarch == "arm64": - return name + "-darwin-arm64", nil - case goos == "linux" && goarch == "amd64": - return name + "-linux-amd64", nil - case goos == "windows" && goarch == "amd64": - return name + "-windows-amd64.exe", nil - default: - return "", fmt.Errorf("no release artifact for %s/%s", goos, goarch) + osName := strings.ToLower(strings.TrimSpace(goos)) + archName := strings.ToLower(strings.TrimSpace(goarch)) + if osName == "" || archName == "" { + return "", errors.New("goos and goarch must not be empty") } + + assetTemplate := strings.TrimSpace(template) + if assetTemplate == "" { + assetTemplate = defaultAssetNameTemplate + } + + ext := "" + if osName == "windows" { + ext = ".exe" + } + + replaced := strings.NewReplacer( + "{binary}", name, + "{os}", osName, + "{arch}", archName, + "{ext}", ext, + ).Replace(assetTemplate) + replaced = strings.TrimSpace(replaced) + if replaced == "" { + return "", errors.New("asset name template resolved to an empty value") + } + if strings.ContainsRune(replaced, '/') || strings.ContainsRune(replaced, '\\') { + return "", fmt.Errorf("asset name %q must not contain path separators", replaced) + } + + return replaced, nil } func FetchLatestRelease(ctx context.Context, client *http.Client, releaseURL string, auth Auth, source ReleaseSource) (Release, error) { @@ -232,7 +372,26 @@ func (r Release) AssetURL(assetName, releaseURL string) (string, error) { } } - return "", fmt.Errorf("latest release does not contain asset %q", assetName) + availableAssets := make([]string, 0, len(r.Assets.Links)) + for _, link := range r.Assets.Links { + if name := strings.TrimSpace(link.Name); name != "" { + availableAssets = append(availableAssets, name) + } + } + sort.Strings(availableAssets) + if len(availableAssets) == 0 { + return "", fmt.Errorf("latest release does not contain asset %q", assetName) + } + + preview := availableAssets + if len(preview) > 8 { + preview = preview[:8] + } + return "", fmt.Errorf( + "latest release does not contain asset %q (available: %s)", + assetName, + strings.Join(preview, ", "), + ) } func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, targetPath string, auth Auth, source ReleaseSource) (string, error) { @@ -292,9 +451,57 @@ func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, ta return tempPath, nil } +func VerifyReleaseAssetChecksum( + ctx context.Context, + client *http.Client, + release Release, + releaseURL string, + assetName string, + artifactPath string, + auth Auth, + source ReleaseSource, +) error { + source = normalizeSource(source) + + checksumAssetName := resolveChecksumAssetName(assetName, source.ChecksumAssetName) + checksumURL, err := release.AssetURL(checksumAssetName, releaseURL) + if err != nil { + if source.ChecksumRequired { + return fmt.Errorf("checksum verification: %w", err) + } + return nil + } + + checksumBody, err := downloadAssetBytes(ctx, client, checksumURL, auth, source) + if err != nil { + return fmt.Errorf("checksum verification: %w", err) + } + + expected, err := parseChecksum(string(checksumBody), assetName) + if err != nil { + return fmt.Errorf("checksum verification: %w", err) + } + + actual, err := fileSHA256(artifactPath) + if err != nil { + return fmt.Errorf("checksum verification: %w", err) + } + + if !strings.EqualFold(expected, actual) { + return fmt.Errorf( + "checksum mismatch for asset %q: expected %s, got %s", + assetName, + expected, + actual, + ) + } + + return nil +} + func ReplaceExecutable(downloadPath, targetPath string) error { if runtime.GOOS == "windows" { - return errors.New("self-update is not supported on windows") + return errors.New("self-update is not supported on windows without a custom ReplaceExecutable hook") } if err := os.Rename(downloadPath, targetPath); err != nil { return fmt.Errorf("replace executable %q: %w", targetPath, err) @@ -304,9 +511,69 @@ func ReplaceExecutable(downloadPath, targetPath string) error { func normalizeSource(source ReleaseSource) ReleaseSource { source.Name = strings.TrimSpace(source.Name) + source.Driver = strings.ToLower(strings.TrimSpace(source.Driver)) + source.Repository = strings.Trim(strings.TrimSpace(source.Repository), "/") source.BaseURL = strings.TrimRight(strings.TrimSpace(source.BaseURL), "/") source.LatestReleaseURL = strings.TrimSpace(source.LatestReleaseURL) + source.AssetNameTemplate = strings.TrimSpace(source.AssetNameTemplate) + source.ChecksumAssetName = strings.TrimSpace(source.ChecksumAssetName) + source.Token = strings.TrimSpace(source.Token) source.TokenHeader = strings.TrimSpace(source.TokenHeader) + source.TokenPrefix = strings.TrimSpace(source.TokenPrefix) + + envNames := source.TokenEnvNames[:0] + for _, envName := range source.TokenEnvNames { + if trimmed := strings.TrimSpace(envName); trimmed != "" { + envNames = append(envNames, trimmed) + } + } + source.TokenEnvNames = envNames + + switch source.Driver { + case "gitea": + if source.Name == "" { + source.Name = "Gitea releases" + } + if source.TokenHeader == "" { + source.TokenHeader = "Authorization" + } + if source.TokenPrefix == "" { + source.TokenPrefix = "token " + } + if len(source.TokenEnvNames) == 0 { + source.TokenEnvNames = []string{"GITEA_TOKEN"} + } + case "gitlab": + if source.Name == "" { + source.Name = "GitLab releases" + } + if source.BaseURL == "" { + source.BaseURL = "https://gitlab.com" + } + if source.TokenHeader == "" { + source.TokenHeader = "PRIVATE-TOKEN" + } + if len(source.TokenEnvNames) == 0 { + source.TokenEnvNames = []string{"GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"} + } + case "github": + if source.Name == "" { + source.Name = "GitHub releases" + } + if source.BaseURL == "" { + source.BaseURL = "https://api.github.com" + } + if source.TokenHeader == "" { + source.TokenHeader = "Authorization" + } + if source.TokenPrefix == "" { + source.TokenPrefix = "Bearer " + } + if len(source.TokenEnvNames) == 0 { + source.TokenEnvNames = []string{"GITHUB_TOKEN"} + } + } + return source } @@ -375,3 +642,211 @@ func (a Auth) maybeHint(statusCode int, body []byte, source ReleaseSource) error source.TokenEnvNames[1], ) } + +func parseReleaseLinks(raw json.RawMessage) []ReleaseLink { + if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" { + return nil + } + + parseLinks := func(payload []releaseLinkPayload) []ReleaseLink { + links := make([]ReleaseLink, 0, len(payload)) + for _, item := range payload { + name := strings.TrimSpace(item.Name) + assetURL := firstNonEmpty(item.DirectAssetURL, item.BrowserDownloadURL, item.URL) + if name == "" || strings.TrimSpace(assetURL) == "" { + continue + } + links = append(links, ReleaseLink{ + Name: name, + URL: strings.TrimSpace(assetURL), + }) + } + return links + } + + var asObject releaseAssetsPayload + if err := json.Unmarshal(raw, &asObject); err == nil && len(asObject.Links) > 0 { + return parseLinks(asObject.Links) + } + + var asArray []releaseLinkPayload + if err := json.Unmarshal(raw, &asArray); err == nil && len(asArray) > 0 { + return parseLinks(asArray) + } + + return nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func withTokenPrefix(token, prefix string) string { + trimmedToken := strings.TrimSpace(token) + if trimmedToken == "" { + return "" + } + + trimmedPrefix := strings.TrimSpace(prefix) + if trimmedPrefix == "" { + return trimmedToken + } + + lowerToken := strings.ToLower(trimmedToken) + lowerPrefix := strings.ToLower(trimmedPrefix) + if strings.HasPrefix(lowerToken, lowerPrefix) { + return trimmedToken + } + + return trimmedPrefix + " " + trimmedToken +} + +func resolveChecksumAssetName(assetName, configured string) string { + value := strings.TrimSpace(configured) + if value == "" { + return assetName + ".sha256" + } + return strings.ReplaceAll(value, "{asset}", assetName) +} + +func downloadAssetBytes(ctx context.Context, client *http.Client, assetURL string, auth Auth, source ReleaseSource) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) + if err != nil { + return nil, fmt.Errorf("build checksum download request: %w", err) + } + req.Header.Set("User-Agent", "mcp updater") + auth.apply(req) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("download checksum asset: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if hint := auth.maybeHint(resp.StatusCode, body, source); hint != nil { + return nil, fmt.Errorf("download checksum asset: %w", hint) + } + return nil, fmt.Errorf( + "download checksum asset: unexpected status %d: %s", + resp.StatusCode, + strings.TrimSpace(string(body)), + ) + } + + content, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + if err != nil { + return nil, fmt.Errorf("read checksum asset: %w", err) + } + return content, nil +} + +func parseChecksum(content, assetName string) (string, error) { + lines := strings.Split(content, "\n") + fallbackSingle := "" + + for _, raw := range lines { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.HasPrefix(strings.ToUpper(line), "SHA256 (") { + openIndex := strings.Index(line, "(") + closeIndex := strings.LastIndex(line, ")") + equalIndex := strings.LastIndex(line, "=") + if openIndex >= 0 && closeIndex > openIndex && equalIndex > closeIndex { + name := strings.TrimSpace(line[openIndex+1 : closeIndex]) + hash := strings.TrimSpace(line[equalIndex+1:]) + if isSHA256Hex(hash) && matchesAssetName(name, assetName) { + return strings.ToLower(hash), nil + } + } + continue + } + + fields := strings.Fields(line) + if len(fields) > 0 && isSHA256Hex(fields[0]) { + if len(fields) == 1 { + if fallbackSingle == "" { + fallbackSingle = strings.ToLower(fields[0]) + } + continue + } + + name := strings.TrimSpace(strings.TrimPrefix(fields[1], "*")) + if matchesAssetName(name, assetName) { + return strings.ToLower(fields[0]), nil + } + continue + } + + colonIndex := strings.Index(line, ":") + if colonIndex > 0 && colonIndex < len(line)-1 { + left := strings.TrimSpace(line[:colonIndex]) + right := strings.TrimSpace(line[colonIndex+1:]) + switch { + case isSHA256Hex(left) && matchesAssetName(right, assetName): + return strings.ToLower(left), nil + case isSHA256Hex(right) && matchesAssetName(left, assetName): + return strings.ToLower(right), nil + } + } + } + + if fallbackSingle != "" { + return fallbackSingle, nil + } + + return "", fmt.Errorf("checksum file does not contain a sha256 for asset %q", assetName) +} + +func matchesAssetName(candidate, assetName string) bool { + name := strings.TrimSpace(strings.TrimPrefix(candidate, "*")) + name = strings.TrimPrefix(name, "./") + if name == assetName { + return true + } + + name = strings.ReplaceAll(name, "\\", "/") + if path.Base(name) == assetName { + return true + } + return false +} + +func isSHA256Hex(value string) bool { + if len(value) != 64 { + return false + } + for _, r := range value { + switch { + case r >= '0' && r <= '9': + case r >= 'a' && r <= 'f': + case r >= 'A' && r <= 'F': + default: + return false + } + } + return true +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open downloaded artifact: %w", err) + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", fmt.Errorf("hash downloaded artifact: %w", err) + } + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/update/update_test.go b/update/update_test.go index 047ac74..ab427e3 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -3,7 +3,10 @@ package update import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "io" "net/http" "os" @@ -24,13 +27,20 @@ func TestAssetName(t *testing.T) { {name: "darwin amd64", goos: "darwin", goarch: "amd64", want: "graylog-mcp-darwin-amd64"}, {name: "darwin arm64", goos: "darwin", goarch: "arm64", want: "graylog-mcp-darwin-arm64"}, {name: "linux amd64", goos: "linux", goarch: "amd64", want: "graylog-mcp-linux-amd64"}, - {name: "windows amd64", goos: "windows", goarch: "amd64", want: "graylog-mcp-windows-amd64.exe"}, - {name: "unsupported", goos: "linux", goarch: "arm64", wantErr: "no release artifact"}, + {name: "linux arm64", goos: "linux", goarch: "arm64", want: "graylog-mcp-linux-arm64"}, + {name: "windows arm64", goos: "windows", goarch: "arm64", want: "graylog-mcp-windows-arm64.exe"}, + {name: "missing binary", goos: "linux", goarch: "amd64", wantErr: "binary name must not be empty"}, + {name: "missing platform", goos: "", goarch: "amd64", wantErr: "goos and goarch must not be empty"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := AssetName("graylog-mcp", tt.goos, tt.goarch) + binaryName := "graylog-mcp" + if tt.name == "missing binary" { + binaryName = " " + } + + got, err := AssetName(binaryName, tt.goos, tt.goarch) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %v, want substring %q", err, tt.wantErr) @@ -47,6 +57,91 @@ func TestAssetName(t *testing.T) { } } +func TestAssetNameWithTemplate(t *testing.T) { + got, err := AssetNameWithTemplate("graylog-mcp", "linux", "amd64", "{binary}_{os}_{arch}") + if err != nil { + t.Fatalf("AssetNameWithTemplate: %v", err) + } + if got != "graylog-mcp_linux_amd64" { + t.Fatalf("got %q", got) + } + + _, err = AssetNameWithTemplate("graylog-mcp", "linux", "amd64", "{binary}/{os}/{arch}") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "must not contain path separators") { + t.Fatalf("error = %v", err) + } +} + +func TestResolveLatestReleaseURL(t *testing.T) { + got, err := ResolveLatestReleaseURL("https://custom/latest", ReleaseSource{ + Driver: "gitea", + Repository: "org/repo", + BaseURL: "https://gitea.example.com", + }) + if err != nil { + t.Fatalf("ResolveLatestReleaseURL explicit: %v", err) + } + if got != "https://custom/latest" { + t.Fatalf("release url = %q", got) + } + + got, err = ResolveLatestReleaseURL("", ReleaseSource{ + LatestReleaseURL: "https://manifest/latest", + }) + if err != nil { + t.Fatalf("ResolveLatestReleaseURL from source: %v", err) + } + if got != "https://manifest/latest" { + t.Fatalf("release url = %q", got) + } + + got, err = ResolveLatestReleaseURL("", ReleaseSource{ + Driver: "gitea", + Repository: "org/repo", + BaseURL: "https://gitea.example.com", + }) + if err != nil { + t.Fatalf("ResolveLatestReleaseURL gitea: %v", err) + } + if got != "https://gitea.example.com/api/v1/repos/org/repo/releases/latest" { + t.Fatalf("release url = %q", got) + } + + got, err = ResolveLatestReleaseURL("", ReleaseSource{ + Driver: "gitlab", + Repository: "group/sub/repo", + BaseURL: "https://gitlab.example.com", + }) + if err != nil { + t.Fatalf("ResolveLatestReleaseURL gitlab: %v", err) + } + if got != "https://gitlab.example.com/api/v4/projects/group%2Fsub%2Frepo/releases/permalink/latest" { + t.Fatalf("release url = %q", got) + } + + got, err = ResolveLatestReleaseURL("", ReleaseSource{ + Driver: "github", + Repository: "org/repo", + }) + if err != nil { + t.Fatalf("ResolveLatestReleaseURL github: %v", err) + } + if got != "https://api.github.com/repos/org/repo/releases/latest" { + t.Fatalf("release url = %q", got) + } + + _, err = ResolveLatestReleaseURL("", ReleaseSource{Driver: "gitea"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "requires repository") { + t.Fatalf("error = %v", err) + } +} + func TestResolveUpdateTargetFollowsSymlink(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink behavior differs on windows") @@ -87,20 +182,37 @@ func TestReleaseAssetURLResolvesRelativeLinks(t *testing.T) { } } +func TestReleaseAssetURLErrorIncludesAvailableAssets(t *testing.T) { + release := Release{} + release.Assets.Links = []ReleaseLink{ + {Name: "my-mcp-linux-amd64", URL: "/downloads/my-mcp-linux-amd64"}, + {Name: "my-mcp-darwin-arm64", URL: "/downloads/my-mcp-darwin-arm64"}, + } + + _, err := release.AssetURL("my-mcp-linux-arm64", "https://releases.example.com/latest") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "available: my-mcp-darwin-arm64, my-mcp-linux-amd64") { + t.Fatalf("error = %v", err) + } +} + func TestResolveAuthPrefersExplicitToken(t *testing.T) { t.Setenv("RELEASE_TOKEN", "env-token") auth := ResolveAuth("explicit-token", ReleaseSource{ - Name: "release endpoint", - BaseURL: "https://releases.example.com", - TokenHeader: "X-Release-Token", - TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, + TokenHeader: "Authorization", + TokenPrefix: "Bearer", + TokenEnvNames: []string{ + "RELEASE_TOKEN", + }, }) - if auth.Header != "X-Release-Token" { - t.Fatalf("header = %q, want X-Release-Token", auth.Header) + if auth.Header != "Authorization" { + t.Fatalf("header = %q, want Authorization", auth.Header) } - if auth.Token != "explicit-token" { - t.Fatalf("token = %q, want explicit token", auth.Token) + if auth.Token != "Bearer explicit-token" { + t.Fatalf("token = %q, want prefixed explicit token", auth.Token) } } @@ -108,10 +220,11 @@ func TestResolveAuthReadsEnvironment(t *testing.T) { t.Setenv("RELEASE_PRIVATE_TOKEN", "env-token") auth := ResolveAuth("", ReleaseSource{ - Name: "release endpoint", - BaseURL: "https://releases.example.com", - TokenHeader: "X-Release-Token", - TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, + TokenHeader: "X-Release-Token", + TokenEnvNames: []string{ + "RELEASE_TOKEN", + "RELEASE_PRIVATE_TOKEN", + }, }) if auth.Header != "X-Release-Token" { t.Fatalf("header = %q, want X-Release-Token", auth.Header) @@ -155,6 +268,86 @@ func TestFetchLatestReleaseAddsConfiguredAuthHeader(t *testing.T) { } } +func TestFetchLatestReleaseSupportsGitHubAssets(t *testing.T) { + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + body := `{ + "tag_name":"v1.2.3", + "assets":[ + {"name":"my-mcp-linux-amd64","browser_download_url":"https://example.com/my-mcp-linux-amd64"} + ] + }` + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }), + } + + release, err := FetchLatestRelease( + context.Background(), + client, + "https://api.github.com/repos/org/repo/releases/latest", + Auth{}, + ReleaseSource{Name: "GitHub releases"}, + ) + if err != nil { + t.Fatalf("FetchLatestRelease: %v", err) + } + + assetURL, err := release.AssetURL("my-mcp-linux-amd64", "https://api.github.com/repos/org/repo/releases/latest") + if err != nil { + t.Fatalf("AssetURL: %v", err) + } + if assetURL != "https://example.com/my-mcp-linux-amd64" { + t.Fatalf("assetURL = %q", assetURL) + } +} + +func TestFetchLatestReleaseSupportsGitLabDirectAssetURL(t *testing.T) { + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + body := `{ + "tag_name":"v1.2.3", + "assets":{ + "links":[ + { + "name":"my-mcp-linux-amd64", + "url":"https://gitlab.example.com/fallback", + "direct_asset_url":"https://gitlab.example.com/direct/my-mcp-linux-amd64" + } + ] + } + }` + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }), + } + + release, err := FetchLatestRelease( + context.Background(), + client, + "https://gitlab.example.com/api/v4/projects/org%2Frepo/releases/permalink/latest", + Auth{}, + ReleaseSource{Name: "GitLab releases"}, + ) + if err != nil { + t.Fatalf("FetchLatestRelease: %v", err) + } + + assetURL, err := release.AssetURL("my-mcp-linux-amd64", "https://gitlab.example.com/api/v4/projects/org%2Frepo/releases/permalink/latest") + if err != nil { + t.Fatalf("AssetURL: %v", err) + } + if assetURL != "https://gitlab.example.com/direct/my-mcp-linux-amd64" { + t.Fatalf("assetURL = %q", assetURL) + } +} + func TestFetchLatestReleaseHintsWhenAuthIsMissing(t *testing.T) { client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { @@ -282,6 +475,474 @@ func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { } } +func TestRunUsesDriverWithoutExplicitLatestReleaseURL(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + latestURL := "https://gitea.example.com/api/v1/repos/org/graylog-mcp/releases/latest" + replaceCalled := false + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case latestURL: + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://gitea.example.com/downloads/artifact"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://gitea.example.com/downloads/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + BinaryName: "graylog-mcp", + ReleaseSource: ReleaseSource{ + Driver: "gitea", + Repository: "org/graylog-mcp", + BaseURL: "https://gitea.example.com", + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + replaceCalled = true + + data, err := os.ReadFile(downloadPath) + if err != nil { + return err + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + + if !replaceCalled { + t.Fatal("custom replace hook was not called") + } +} + +func TestRunVerifiesChecksumWhenSidecarAvailable(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + const newBinary = "new-binary" + hash := sha256.Sum256([]byte(newBinary)) + checksum := hex.EncodeToString(hash[:]) + " " + assetName + "\n" + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + {Name: assetName + ".sha256", URL: "https://releases.example.com/artifact.sha256"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(newBinary)), + }, nil + case "https://releases.example.com/artifact.sha256": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(checksum)), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReplaceExecutable: func(downloadPath, targetPath string) error { + data, err := os.ReadFile(downloadPath) + if err != nil { + return err + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("ReadFile target: %v", err) + } + if string(got) != newBinary { + t.Fatalf("target content = %q, want %q", string(got), newBinary) + } +} + +func TestRunFailsOnChecksumMismatch(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + {Name: assetName + ".sha256", URL: "https://releases.example.com/artifact.sha256"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + case "https://releases.example.com/artifact.sha256": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa " + assetName)), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReplaceExecutable: func(downloadPath, targetPath string) error { + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("error = %v", err) + } +} + +func TestRunFailsWhenChecksumRequiredAndMissing(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReleaseSource: ReleaseSource{ + ChecksumRequired: true, + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "checksum verification") { + t.Fatalf("error = %v", err) + } +} + +func TestRunInvokesValidationHook(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + validateCalled := false + replaceCalled := false + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ValidateDownloaded: func(_ context.Context, input ValidationInput) error { + validateCalled = true + if input.AssetName != assetName { + t.Fatalf("asset name = %q", input.AssetName) + } + data, readErr := os.ReadFile(input.DownloadPath) + if readErr != nil { + return readErr + } + if string(data) != "new-binary" { + t.Fatalf("downloaded content = %q", string(data)) + } + return nil + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + replaceCalled = true + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + + if !validateCalled { + t.Fatal("validation hook was not called") + } + if !replaceCalled { + t.Fatal("replace hook was not called") + } +} + +func TestRunStopsWhenValidationHookFails(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + replaceCalled := false + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ValidateDownloaded: func(context.Context, ValidationInput) error { + return errors.New("signature invalid") + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + replaceCalled = true + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "validate downloaded artifact") { + t.Fatalf("error = %v", err) + } + if replaceCalled { + t.Fatal("replace hook should not have been called") + } +} + func TestRunSkipsWhenAlreadyOnLatestRelease(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { @@ -364,7 +1025,7 @@ func TestRunSkipsWhenAlreadyOnLatestRelease(t *testing.T) { } } -func TestRunRequiresLatestReleaseURL(t *testing.T) { +func TestRunRequiresLatestReleaseURLOrDriver(t *testing.T) { target := filepath.Join(t.TempDir(), "graylog-mcp") if err := os.WriteFile(target, []byte("current-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) -- 2.45.2 From c4c461105fb8b53785d931b2a1ffd096c555ff51 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 15:39:21 +0200 Subject: [PATCH 11/79] feat(scaffold): add MCP binary scaffold generator --- README.md | 27 ++ scaffold/scaffold.go | 738 ++++++++++++++++++++++++++++++++++++++ scaffold/scaffold_test.go | 183 ++++++++++ 3 files changed, 948 insertions(+) create mode 100644 scaffold/scaffold.go create mode 100644 scaffold/scaffold_test.go diff --git a/README.md b/README.md index ce2a5be..72270a8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ go get gitea.lclr.dev/AI/mcp-framework - `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, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. +- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, wiring de base et README de démarrage). - `secretstore` : lecture/écriture de secrets dans le wallet natif. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. @@ -159,6 +160,32 @@ _ = bootstrapInfo _ = scaffoldInfo ``` +## Scaffolding + +Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : + +- arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) +- wiring initial `bootstrap + config + secretstore + update` +- `README.md` de démarrage + +Exemple : + +```go +result, err := scaffold.Generate(scaffold.Options{ + TargetDir: "./my-mcp", + ModulePath: "gitea.lclr.dev/AI/my-mcp", + BinaryName: "my-mcp", + Description: "Client MCP interne", + DefaultProfile: "prod", + Profiles: []string{"dev", "prod"}, +}) +if err != nil { + return err +} + +fmt.Printf("Scaffold generated in %s (%d files)\n", result.Root, len(result.Files)) +``` + ## Config JSON Le package `config` stocke une structure générique par profil dans un JSON privé diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go new file mode 100644 index 0000000..06b5da4 --- /dev/null +++ b/scaffold/scaffold.go @@ -0,0 +1,738 @@ +package scaffold + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "text/template" + "unicode" +) + +var ( + ErrTargetDirRequired = errors.New("target directory is required") + ErrFileExists = errors.New("target file already exists") +) + +type Options struct { + TargetDir string + ModulePath string + BinaryName string + Description string + DocsURL string + DefaultProfile string + Profiles []string + KnownEnvironmentVariables []string + SecretStorePolicy string + ReleaseDriver string + ReleaseBaseURL string + ReleaseRepository string + ReleaseTokenEnv string + Overwrite bool +} + +type Result struct { + Root string + Files []string +} + +type normalizedOptions struct { + TargetDir string + ModulePath string + BinaryName string + Description string + DocsURL string + DefaultProfile string + Profiles []string + KnownEnvironmentVariables []string + ProfileEnv string + BaseURLEnv string + TokenEnv string + SecretStorePolicy string + ReleaseDriver string + ReleaseBaseURL string + ReleaseRepository string + ReleaseTokenEnv string + Overwrite bool +} + +func Generate(options Options) (Result, error) { + normalized, err := normalizeOptions(options) + if err != nil { + return Result{}, err + } + + if err := os.MkdirAll(normalized.TargetDir, 0o755); err != nil { + return Result{}, fmt.Errorf("create scaffold target %q: %w", normalized.TargetDir, err) + } + + files := []generatedFile{ + {Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized)}, + {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized)}, + {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized)}, + {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized)}, + {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized)}, + {Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized)}, + } + + written := make([]string, 0, len(files)) + for _, file := range files { + fullPath := filepath.Join(normalized.TargetDir, file.Path) + if err := writeFile(fullPath, file.Content, normalized.Overwrite); err != nil { + return Result{}, err + } + written = append(written, file.Path) + } + + sort.Strings(written) + return Result{ + Root: normalized.TargetDir, + Files: written, + }, nil +} + +type generatedFile struct { + Path string + Content string +} + +func writeFile(path, content string, overwrite bool) error { + if !overwrite { + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("%w: %s", ErrFileExists, path) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat scaffold file %q: %w", path, err) + } + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create scaffold directory %q: %w", dir, err) + } + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("write scaffold file %q: %w", path, err) + } + + return nil +} + +func renderTemplate(src string, data normalizedOptions) string { + tpl := template.Must(template.New("scaffold").Parse(src)) + + var builder strings.Builder + if err := tpl.Execute(&builder, data); err != nil { + panic(err) + } + + return builder.String() +} + +func normalizeOptions(options Options) (normalizedOptions, error) { + targetDir := strings.TrimSpace(options.TargetDir) + if targetDir == "" { + return normalizedOptions{}, ErrTargetDirRequired + } + + resolvedTarget, err := filepath.Abs(targetDir) + if err != nil { + return normalizedOptions{}, fmt.Errorf("resolve scaffold target %q: %w", targetDir, err) + } + + binaryName := strings.TrimSpace(options.BinaryName) + if binaryName == "" { + binaryName = sanitizeSlug(filepath.Base(resolvedTarget)) + } + if binaryName == "" { + binaryName = "my-mcp" + } + if strings.ContainsRune(binaryName, os.PathSeparator) { + return normalizedOptions{}, fmt.Errorf("binary name %q must not contain path separators", binaryName) + } + + modulePath := strings.TrimSpace(options.ModulePath) + if modulePath == "" { + modulePath = fmt.Sprintf("example.com/%s", sanitizeModuleSegment(binaryName)) + } + + description := strings.TrimSpace(options.Description) + if description == "" { + description = fmt.Sprintf("Binaire MCP %s.", binaryName) + } + + docsURL := strings.TrimSpace(options.DocsURL) + if docsURL == "" { + docsURL = fmt.Sprintf("https://docs.example.com/%s", binaryName) + } + + defaultProfile := strings.TrimSpace(options.DefaultProfile) + if defaultProfile == "" { + defaultProfile = "default" + } + + profiles := normalizeValues(options.Profiles) + if !slices.Contains(profiles, defaultProfile) { + profiles = append([]string{defaultProfile}, profiles...) + } + if len(profiles) == 0 { + profiles = []string{defaultProfile} + } + + envPrefix := environmentPrefix(binaryName) + profileEnv := envPrefix + "_PROFILE" + baseURLEnv := envPrefix + "_BASE_URL" + tokenEnv := envPrefix + "_API_TOKEN" + + knownEnvironmentVariables := []string{profileEnv, baseURLEnv, tokenEnv} + for _, name := range normalizeValues(options.KnownEnvironmentVariables) { + if !slices.Contains(knownEnvironmentVariables, name) { + knownEnvironmentVariables = append(knownEnvironmentVariables, name) + } + } + + secretStorePolicy := strings.TrimSpace(options.SecretStorePolicy) + if secretStorePolicy == "" { + secretStorePolicy = "auto" + } + + releaseDriver := strings.TrimSpace(options.ReleaseDriver) + if releaseDriver == "" { + releaseDriver = "gitea" + } + + releaseBaseURL := strings.TrimSpace(options.ReleaseBaseURL) + if releaseBaseURL == "" { + releaseBaseURL = "https://gitea.example.com" + } + + releaseRepository := strings.Trim(strings.TrimSpace(options.ReleaseRepository), "/") + if releaseRepository == "" { + releaseRepository = fmt.Sprintf("org/%s", binaryName) + } + + releaseTokenEnv := strings.TrimSpace(options.ReleaseTokenEnv) + if releaseTokenEnv == "" { + releaseTokenEnv = envPrefix + "_RELEASE_TOKEN" + } + + return normalizedOptions{ + TargetDir: resolvedTarget, + ModulePath: modulePath, + BinaryName: binaryName, + Description: description, + DocsURL: docsURL, + DefaultProfile: defaultProfile, + Profiles: profiles, + KnownEnvironmentVariables: knownEnvironmentVariables, + ProfileEnv: profileEnv, + BaseURLEnv: baseURLEnv, + TokenEnv: tokenEnv, + SecretStorePolicy: secretStorePolicy, + ReleaseDriver: releaseDriver, + ReleaseBaseURL: releaseBaseURL, + ReleaseRepository: releaseRepository, + ReleaseTokenEnv: releaseTokenEnv, + Overwrite: options.Overwrite, + }, nil +} + +func normalizeValues(values []string) []string { + normalized := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + normalized = append(normalized, trimmed) + } + return normalized +} + +func sanitizeSlug(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + + var builder strings.Builder + lastDash := false + for _, r := range value { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + builder.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || r == ' ' || r == '.': + if !lastDash && builder.Len() > 0 { + builder.WriteRune('-') + lastDash = true + } + } + } + + result := strings.Trim(builder.String(), "-") + if result == "" { + return "my-mcp" + } + + return result +} + +func sanitizeModuleSegment(binaryName string) string { + segment := sanitizeSlug(binaryName) + if segment == "" { + return "my-mcp" + } + return segment +} + +func environmentPrefix(binaryName string) string { + name := strings.ToUpper(strings.TrimSpace(binaryName)) + if name == "" { + return "MCP" + } + + var builder strings.Builder + lastUnderscore := false + for _, r := range name { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + builder.WriteRune(r) + lastUnderscore = false + default: + if !lastUnderscore { + builder.WriteRune('_') + lastUnderscore = true + } + } + } + + result := strings.Trim(builder.String(), "_") + if result == "" { + return "MCP" + } + return result +} + +const gitignoreTemplate = `bin/ +dist/ +*.log +` + +const goModTemplate = `module {{.ModulePath}} + +go 1.25.0 +` + +const mainTemplate = `package main + +import ( + "context" + "log" + "os" + + "{{.ModulePath}}/internal/app" +) + +var version = "dev" + +func main() { + if err := app.Run(context.Background(), os.Args[1:], version); err != nil { + log.Fatal(err) + } +} +` + +const appTemplate = `package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "gitea.lclr.dev/AI/mcp-framework/bootstrap" + "gitea.lclr.dev/AI/mcp-framework/cli" + "gitea.lclr.dev/AI/mcp-framework/config" + "gitea.lclr.dev/AI/mcp-framework/manifest" + "gitea.lclr.dev/AI/mcp-framework/secretstore" + "gitea.lclr.dev/AI/mcp-framework/update" +) + +type Profile struct { + BaseURL string +} + +type Runtime struct { + ConfigStore config.Store[Profile] + Manifest manifest.File + BinaryName string + Description string + Version string + DefaultProfile string + ProfileEnv string + TokenEnv string + SecretName string + SecretStorePolicy string +} + +func Run(ctx context.Context, args []string, version string) error { + runtime, err := NewRuntime(version) + if err != nil { + return err + } + + return runtime.Run(ctx, args) +} + +func NewRuntime(version string) (Runtime, error) { + manifestFile, _, err := manifest.LoadDefault(".") + if err != nil { + return Runtime{}, err + } + + bootstrapInfo := manifestFile.BootstrapInfo() + scaffoldInfo := manifestFile.ScaffoldInfo() + + binaryName := firstNonEmpty(bootstrapInfo.BinaryName, "{{.BinaryName}}") + description := firstNonEmpty(bootstrapInfo.Description, "{{.Description}}") + defaultProfile := firstNonEmpty(scaffoldInfo.DefaultProfile, "{{.DefaultProfile}}") + profileEnv := "{{.ProfileEnv}}" + tokenEnv := "{{.TokenEnv}}" + if len(scaffoldInfo.KnownEnvironmentVariables) > 0 { + profileEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[0], profileEnv) + } + if len(scaffoldInfo.KnownEnvironmentVariables) > 2 { + tokenEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[2], tokenEnv) + } + + return Runtime{ + ConfigStore: config.NewStore[Profile](binaryName), + Manifest: manifestFile, + BinaryName: binaryName, + Description: description, + Version: firstNonEmpty(strings.TrimSpace(version), "dev"), + DefaultProfile: defaultProfile, + ProfileEnv: profileEnv, + TokenEnv: tokenEnv, + SecretName: binaryName + "-api-token", + SecretStorePolicy: firstNonEmpty(scaffoldInfo.SecretStorePolicy, "{{.SecretStorePolicy}}"), + }, nil +} + +func (r Runtime) Run(ctx context.Context, args []string) error { + return bootstrap.Run(ctx, bootstrap.Options{ + BinaryName: r.BinaryName, + Description: r.Description, + Version: r.Version, + Args: args, + Hooks: bootstrap.Hooks{ + Setup: r.runSetup, + MCP: r.runMCP, + ConfigShow: r.runConfigShow, + ConfigTest: r.runConfigTest, + Update: r.runUpdate, + }, + }) +} + +func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { + stdin, ok := inv.Stdin.(*os.File) + if !ok || stdin == nil { + stdin = os.Stdin + } + + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + cfg, _, err := r.ConfigStore.LoadDefault() + if err != nil { + return err + } + + profileName := r.resolveProfileName(cfg.CurrentProfile) + profile := cfg.Profiles[profileName] + storedToken, _ := r.readToken() + + result, err := cli.RunSetup(cli.SetupOptions{ + Stdin: stdin, + Stdout: stdout, + Fields: []cli.SetupField{ + { + Name: "base_url", + Label: "Base URL", + Type: cli.SetupFieldURL, + Required: true, + Default: profile.BaseURL, + }, + { + Name: "api_token", + Label: "API token", + Type: cli.SetupFieldSecret, + Required: true, + ExistingSecret: storedToken, + }, + }, + }) + if err != nil { + return err + } + + baseURLValue, _ := result.Get("base_url") + tokenValue, _ := result.Get("api_token") + + profile.BaseURL = strings.TrimSpace(baseURLValue.String) + cfg.CurrentProfile = profileName + cfg.Profiles[profileName] = profile + if _, err := r.ConfigStore.SaveDefault(cfg); err != nil { + return err + } + + if !tokenValue.KeptStoredSecret { + store, err := r.openSecretStore() + if err != nil { + return err + } + + if err := store.SetSecret(r.SecretName, "API token", tokenValue.String); err != nil { + if errors.Is(err, secretstore.ErrReadOnly) { + fmt.Fprintf(stdout, "Secret store en lecture seule, exporte %s pour fournir le token.\n", r.TokenEnv) + } else { + return err + } + } + } + + _, err = fmt.Fprintf(stdout, "Configuration sauvegardée pour le profil %q.\n", profileName) + return err +} + +func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error { + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + cfg, _, err := r.ConfigStore.LoadDefault() + if err != nil { + return err + } + + profileName := r.resolveProfileName(cfg.CurrentProfile) + profile, ok := cfg.Profiles[profileName] + if !ok { + return fmt.Errorf("profil %q absent, lance %s setup", profileName, r.BinaryName) + } + + token, err := r.readToken() + if err != nil { + if errors.Is(err, secretstore.ErrNotFound) { + return fmt.Errorf("secret %q introuvable, lance %s setup", r.SecretName, r.BinaryName) + } + return err + } + + fmt.Fprintf(stdout, "MCP prêt sur %s (profil %s).\n", profile.BaseURL, profileName) + fmt.Fprintf(stdout, "Token chargé (%d caractères).\n", len(strings.TrimSpace(token))) + fmt.Fprintln(stdout, "Ajoute ici ta logique métier MCP.") + return nil +} + +func (r Runtime) runConfigShow(_ context.Context, inv bootstrap.Invocation) error { + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + cfg, path, err := r.ConfigStore.LoadDefault() + if err != nil { + return err + } + + payload, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("encode config JSON: %w", err) + } + + if _, err := fmt.Fprintf(stdout, "Config: %s\n", path); err != nil { + return err + } + _, err = fmt.Fprintf(stdout, "%s\n", payload) + return err +} + +func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) error { + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + report := cli.RunDoctor(ctx, cli.DoctorOptions{ + ConfigCheck: cli.NewConfigCheck(r.ConfigStore), + SecretStoreCheck: cli.SecretStoreAvailabilityCheck(r.openSecretStore), + RequiredSecrets: []cli.DoctorSecret{ + {Name: r.SecretName, Label: "API token"}, + }, + SecretStoreFactory: r.openSecretStore, + ManifestDir: ".", + }) + + if err := cli.RenderDoctorReport(stdout, report); err != nil { + return err + } + + if report.HasFailures() { + return errors.New("doctor checks failed") + } + + return nil +} + +func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error { + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + return update.Run(ctx, update.Options{ + CurrentVersion: r.Version, + BinaryName: r.BinaryName, + ReleaseSource: r.Manifest.Update.ReleaseSource(), + Stdout: stdout, + }) +} + +func (r Runtime) openSecretStore() (secretstore.Store, error) { + return secretstore.Open(secretstore.Options{ + ServiceName: r.BinaryName, + BackendPolicy: secretstore.BackendPolicy(r.SecretStorePolicy), + LookupEnv: func(name string) (string, bool) { + if name == r.SecretName { + return os.LookupEnv(r.TokenEnv) + } + return os.LookupEnv(name) + }, + }) +} + +func (r Runtime) readToken() (string, error) { + store, err := r.openSecretStore() + if err != nil { + return "", err + } + + return store.GetSecret(r.SecretName) +} + +func (r Runtime) resolveProfileName(currentProfile string) string { + resolved := cli.ResolveProfileName("", os.Getenv(r.ProfileEnv), currentProfile) + if strings.TrimSpace(resolved) != "" { + return resolved + } + return r.DefaultProfile +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} +` + +const manifestTemplate = `binary_name = "{{.BinaryName}}" +docs_url = "{{.DocsURL}}" + +[update] +source_name = "Release endpoint" +driver = "{{.ReleaseDriver}}" +repository = "{{.ReleaseRepository}}" +base_url = "{{.ReleaseBaseURL}}" +asset_name_template = "{binary}-{os}-{arch}{ext}" +checksum_asset_name = "{asset}.sha256" +checksum_required = false +token_header = "Authorization" +token_prefix = "token" +token_env_names = ["{{.ReleaseTokenEnv}}"] + +[environment] +known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] + +[secret_store] +backend_policy = "{{.SecretStorePolicy}}" + +[profiles] +default = "{{.DefaultProfile}}" +known = [{{- range $index, $value := .Profiles}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] + +[bootstrap] +description = "{{.Description}}" +` + +const readmeTemplate = `# {{.BinaryName}} + +Binaire MCP généré depuis ` + "`mcp-framework`" + `. + +## Arborescence générée + +` + "```text" + ` +. +├── cmd/ +│ └── {{.BinaryName}}/ +│ └── main.go +├── internal/ +│ └── app/ +│ └── app.go +├── .gitignore +├── go.mod +├── mcp.toml +└── README.md +` + "```" + ` + +## Démarrage rapide + +1. Installer les dépendances : + +` + "```bash" + ` +go mod tidy +` + "```" + ` + +2. Vérifier l’aide CLI bootstrap : + +` + "```bash" + ` +go run ./cmd/{{.BinaryName}} help +` + "```" + ` + +3. Initialiser la configuration locale : + +` + "```bash" + ` +go run ./cmd/{{.BinaryName}} setup +` + "```" + ` + +4. Lancer le flux MCP (placeholder) : + +` + "```bash" + ` +go run ./cmd/{{.BinaryName}} mcp +` + "```" + ` + +5. Vérifier la configuration et le manifeste : + +` + "```bash" + ` +go run ./cmd/{{.BinaryName}} config test +` + "```" + ` + +## Points à adapter + +- Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs). +- Compléter la logique métier dans ` + "`internal/app/app.go`" + ` (` + "`runMCP`" + `). +- Ajuster les variables d’environnement connues si besoin. +` diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go new file mode 100644 index 0000000..3e65462 --- /dev/null +++ b/scaffold/scaffold_test.go @@ -0,0 +1,183 @@ +package scaffold + +import ( + "errors" + "go/parser" + "go/token" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { + target := filepath.Join(t.TempDir(), "my-mcp") + + result, err := Generate(Options{ + TargetDir: target, + ModulePath: "example.com/acme/my-mcp", + BinaryName: "my-mcp", + Description: "Client MCP interne", + DefaultProfile: "prod", + Profiles: []string{"dev", "prod"}, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + if result.Root != target { + t.Fatalf("result root = %q, want %q", result.Root, target) + } + + wantFiles := []string{ + ".gitignore", + "README.md", + "cmd/my-mcp/main.go", + "go.mod", + "internal/app/app.go", + "mcp.toml", + } + if !slices.Equal(result.Files, wantFiles) { + t.Fatalf("result files = %v, want %v", result.Files, wantFiles) + } + + for _, path := range wantFiles { + if _, err := os.Stat(filepath.Join(target, filepath.FromSlash(path))); err != nil { + t.Fatalf("generated file %q missing: %v", path, err) + } + } + + mainGo, err := os.ReadFile(filepath.Join(target, "cmd", "my-mcp", "main.go")) + if err != nil { + t.Fatalf("ReadFile main.go: %v", err) + } + if !strings.Contains(string(mainGo), "\"example.com/acme/my-mcp/internal/app\"") { + t.Fatalf("main.go does not import internal app package") + } + if _, err := parser.ParseFile(token.NewFileSet(), "main.go", mainGo, parser.AllErrors); err != nil { + t.Fatalf("generated main.go is invalid Go: %v", err) + } + + appGo, err := os.ReadFile(filepath.Join(target, "internal", "app", "app.go")) + if err != nil { + t.Fatalf("ReadFile app.go: %v", err) + } + for _, snippet := range []string{ + "config.NewStore[Profile]", + "secretstore.Open", + "update.Run", + "manifest.LoadDefault", + "bootstrap.Run", + } { + if !strings.Contains(string(appGo), snippet) { + t.Fatalf("app.go missing snippet %q", snippet) + } + } + if _, err := parser.ParseFile(token.NewFileSet(), "app.go", appGo, parser.AllErrors); err != nil { + t.Fatalf("generated app.go is invalid Go: %v", err) + } + + manifestContent, err := os.ReadFile(filepath.Join(target, "mcp.toml")) + if err != nil { + t.Fatalf("ReadFile mcp.toml: %v", err) + } + for _, snippet := range []string{ + "binary_name = \"my-mcp\"", + "[update]", + "[secret_store]", + "[environment]", + "[profiles]", + "backend_policy = \"auto\"", + } { + if !strings.Contains(string(manifestContent), snippet) { + t.Fatalf("mcp.toml missing snippet %q", snippet) + } + } + + readme, err := os.ReadFile(filepath.Join(target, "README.md")) + if err != nil { + t.Fatalf("ReadFile README.md: %v", err) + } + for _, snippet := range []string{ + "Arborescence générée", + "go run ./cmd/my-mcp setup", + "internal/app/app.go", + } { + if !strings.Contains(string(readme), snippet) { + t.Fatalf("README missing snippet %q", snippet) + } + } +} + +func TestGenerateUsesDefaultsFromTargetDirectory(t *testing.T) { + target := filepath.Join(t.TempDir(), "super-agent-mcp") + + _, err := Generate(Options{TargetDir: target}) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + goModContent, err := os.ReadFile(filepath.Join(target, "go.mod")) + if err != nil { + t.Fatalf("ReadFile go.mod: %v", err) + } + if !strings.Contains(string(goModContent), "module example.com/super-agent-mcp") { + t.Fatalf("go.mod should contain default module path") + } + + manifestContent, err := os.ReadFile(filepath.Join(target, "mcp.toml")) + if err != nil { + t.Fatalf("ReadFile mcp.toml: %v", err) + } + for _, snippet := range []string{ + "binary_name = \"super-agent-mcp\"", + "SUPER_AGENT_MCP_PROFILE", + "SUPER_AGENT_MCP_API_TOKEN", + } { + if !strings.Contains(string(manifestContent), snippet) { + t.Fatalf("mcp.toml missing snippet %q", snippet) + } + } +} + +func TestGenerateFailsWhenFileAlreadyExistsWithoutOverwrite(t *testing.T) { + target := t.TempDir() + readmePath := filepath.Join(target, "README.md") + if err := os.WriteFile(readmePath, []byte("pre-existing"), 0o644); err != nil { + t.Fatalf("WriteFile README.md: %v", err) + } + + _, err := Generate(Options{TargetDir: target}) + if !errors.Is(err, ErrFileExists) { + t.Fatalf("Generate error = %v, want ErrFileExists", err) + } +} + +func TestGenerateOverwritesExistingFilesWhenRequested(t *testing.T) { + target := t.TempDir() + readmePath := filepath.Join(target, "README.md") + if err := os.WriteFile(readmePath, []byte("pre-existing"), 0o644); err != nil { + t.Fatalf("WriteFile README.md: %v", err) + } + + _, err := Generate(Options{TargetDir: target, Overwrite: true}) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + readmeContent, err := os.ReadFile(readmePath) + if err != nil { + t.Fatalf("ReadFile README.md: %v", err) + } + if !strings.Contains(string(readmeContent), "Démarrage rapide") { + t.Fatalf("README should be overwritten with scaffold content") + } +} + +func TestGenerateRequiresTargetDirectory(t *testing.T) { + _, err := Generate(Options{}) + if !errors.Is(err, ErrTargetDirRequired) { + t.Fatalf("Generate error = %v, want ErrTargetDirRequired", err) + } +} -- 2.45.2 From d42a790bc07c414baf6384c5e4ca1a1598e335cd Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 15:59:18 +0200 Subject: [PATCH 12/79] feat(cli): add scaffold init command --- README.md | 21 ++++ cmd/mcp-framework/main.go | 200 +++++++++++++++++++++++++++++++++ cmd/mcp-framework/main_test.go | 101 +++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 cmd/mcp-framework/main.go create mode 100644 cmd/mcp-framework/main_test.go diff --git a/README.md b/README.md index 72270a8..b3fb9b3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,27 @@ pas une application MCP complète. go get gitea.lclr.dev/AI/mcp-framework ``` +## CLI de scaffold + +Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go : + +```bash +go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest +mcp-framework scaffold init \ + --target ./my-mcp \ + --module example.com/my-mcp \ + --binary my-mcp \ + --profiles dev,prod +``` + +Puis dans le projet généré : + +```bash +cd my-mcp +go mod tidy +go run ./cmd/my-mcp help +``` + ## Packages - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go new file mode 100644 index 0000000..d6f98b9 --- /dev/null +++ b/cmd/mcp-framework/main.go @@ -0,0 +1,200 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold" +) + +const toolName = "mcp-framework" + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run(args []string, stdout, stderr io.Writer) error { + if stdout == nil { + stdout = io.Discard + } + if stderr == nil { + stderr = io.Discard + } + + if len(args) == 0 || isHelpArg(args[0]) { + printGlobalHelp(stdout) + return nil + } + + switch args[0] { + case "scaffold": + return runScaffold(args[1:], stdout, stderr) + default: + return fmt.Errorf("unknown command %q", args[0]) + } +} + +func runScaffold(args []string, stdout, stderr io.Writer) error { + if len(args) == 0 || isHelpArg(args[0]) { + printScaffoldHelp(stdout) + return nil + } + + switch args[0] { + case "init": + return runScaffoldInit(args[1:], stdout, stderr) + default: + return fmt.Errorf("unknown scaffold subcommand %q", args[0]) + } +} + +func runScaffoldInit(args []string, stdout, stderr io.Writer) error { + if shouldShowHelp(args) { + printScaffoldInitHelp(stdout) + return nil + } + + fs := flag.NewFlagSet("scaffold init", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var target string + var modulePath string + var binaryName string + var description string + var docsURL string + var defaultProfile string + var profiles string + var knownEnv string + var secretStorePolicy string + var releaseDriver string + var releaseBaseURL string + var releaseRepository string + var releaseTokenEnv string + var overwrite bool + + fs.StringVar(&target, "target", "", "Répertoire cible du nouveau projet (requis)") + fs.StringVar(&modulePath, "module", "", "Chemin de module Go du projet généré") + fs.StringVar(&binaryName, "binary", "", "Nom du binaire généré") + fs.StringVar(&description, "description", "", "Description bootstrap du binaire") + fs.StringVar(&docsURL, "docs-url", "", "URL de documentation du projet") + fs.StringVar(&defaultProfile, "default-profile", "", "Profil par défaut") + fs.StringVar(&profiles, "profiles", "", "Liste CSV de profils connus") + fs.StringVar(&knownEnv, "known-env", "", "Liste CSV de variables d'environnement connues") + fs.StringVar(&secretStorePolicy, "secret-store-policy", "", "Politique secret store (auto, keyring-any, kwallet-only, env-only)") + fs.StringVar(&releaseDriver, "release-driver", "", "Driver de release (gitea, gitlab, github)") + fs.StringVar(&releaseBaseURL, "release-base-url", "", "Base URL de la forge release") + fs.StringVar(&releaseRepository, "release-repository", "", "Repository release (org/repo)") + fs.StringVar(&releaseTokenEnv, "release-token-env", "", "Nom de variable d'environnement pour le token release") + fs.BoolVar(&overwrite, "overwrite", false, "Autorise l'écrasement des fichiers existants") + + if err := fs.Parse(args); err != nil { + _ = stderr + return fmt.Errorf("parse scaffold init flags: %w", err) + } + + if fs.NArg() > 0 { + return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) + } + + if strings.TrimSpace(target) == "" { + return errors.New("--target is required") + } + + result, err := scaffoldpkg.Generate(scaffoldpkg.Options{ + TargetDir: target, + ModulePath: modulePath, + BinaryName: binaryName, + Description: description, + DocsURL: docsURL, + DefaultProfile: defaultProfile, + Profiles: parseCSV(profiles), + KnownEnvironmentVariables: parseCSV(knownEnv), + SecretStorePolicy: secretStorePolicy, + ReleaseDriver: releaseDriver, + ReleaseBaseURL: releaseBaseURL, + ReleaseRepository: releaseRepository, + ReleaseTokenEnv: releaseTokenEnv, + Overwrite: overwrite, + }) + if err != nil { + return err + } + + if _, err := fmt.Fprintf(stdout, "Scaffold generated in %s\n", result.Root); err != nil { + return err + } + for _, file := range result.Files { + if _, err := fmt.Fprintf(stdout, "- %s\n", file); err != nil { + return err + } + } + + return nil +} + +func printGlobalHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", + toolName, + toolName, + ) +} + +func printScaffoldHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s scaffold init [flags]\n\nSubcommands:\n init Génère un nouveau squelette MCP\n", + toolName, + ) +} + +func printScaffoldInitHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s scaffold init --target [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --overwrite Écraser les fichiers existants\n", + toolName, + ) +} + +func shouldShowHelp(args []string) bool { + for _, arg := range args { + if isHelpArg(arg) { + return true + } + } + return false +} + +func isHelpArg(arg string) bool { + switch strings.TrimSpace(arg) { + case "-h", "--help", "help": + return true + default: + return false + } +} + +func parseCSV(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + result = append(result, trimmed) + } + return result +} diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go new file mode 100644 index 0000000..6f32c6f --- /dev/null +++ b/cmd/mcp-framework/main_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunPrintsGlobalHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + if err := run(nil, &stdout, &stderr); err != nil { + t.Fatalf("run returned error: %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "mcp-framework ") { + t.Fatalf("global help should mention command usage: %q", output) + } + if !strings.Contains(output, "scaffold init") { + t.Fatalf("global help should mention scaffold init: %q", output) + } +} + +func TestRunScaffoldInitCreatesProject(t *testing.T) { + target := filepath.Join(t.TempDir(), "demo-mcp") + args := []string{ + "scaffold", "init", + "--target", target, + "--module", "example.com/demo-mcp", + "--binary", "demo-mcp", + "--profiles", "dev,prod", + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + if err := run(args, &stdout, &stderr); err != nil { + t.Fatalf("run returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(target, "cmd", "demo-mcp", "main.go")); err != nil { + t.Fatalf("generated main.go missing: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "internal", "app", "app.go")); err != nil { + t.Fatalf("generated app.go missing: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "mcp.toml")); err != nil { + t.Fatalf("generated mcp.toml missing: %v", err) + } + + if !strings.Contains(stdout.String(), "Scaffold generated in") { + t.Fatalf("stdout should include generation summary: %q", stdout.String()) + } +} + +func TestRunScaffoldInitRequiresTarget(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := run([]string{"scaffold", "init"}, &stdout, &stderr) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--target is required") { + t.Fatalf("error = %v", err) + } +} + +func TestRunUnknownCommandReturnsError(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := run([]string{"boom"}, &stdout, &stderr) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "unknown command") { + t.Fatalf("error = %v", err) + } +} + +func TestScaffoldInitHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + if err := run([]string{"scaffold", "init", "--help"}, &stdout, &stderr); err != nil { + t.Fatalf("run returned error: %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "--target") { + t.Fatalf("init help should mention --target: %q", output) + } + if !strings.Contains(output, "--overwrite") { + t.Fatalf("init help should mention --overwrite: %q", output) + } +} -- 2.45.2 From 8c4f88ea934f09b2d0734ff3b79cc7f04370e825 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 16:31:26 +0200 Subject: [PATCH 13/79] feat(bootstrap): add command alias expansion --- bootstrap/bootstrap.go | 61 +++++++++++++++++++++++++++- bootstrap/bootstrap_test.go | 79 +++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 3a14226..4d5521b 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -47,6 +47,7 @@ type Options struct { BinaryName string Description string Version string + Aliases map[string][]string Args []string Stdin io.Reader Stdout io.Writer @@ -113,7 +114,7 @@ func Run(ctx context.Context, opts Options) error { return ErrBinaryNameRequired } - command, commandArgs, showHelp := parseArgs(normalized.Args) + command, commandArgs, showHelp := parseArgs(expandAliases(normalized.Args, normalized.Aliases)) if showHelp { return printHelp(normalized, command, commandArgs) } @@ -168,6 +169,7 @@ func normalize(opts Options) Options { } func parseArgs(args []string) (command string, commandArgs []string, showHelp bool) { + args = trimArgs(args) if len(args) == 0 { return "", nil, false } @@ -193,6 +195,63 @@ func parseArgs(args []string) (command string, commandArgs []string, showHelp bo return command, commandArgs, false } +func expandAliases(args []string, aliases map[string][]string) []string { + args = trimArgs(args) + if len(args) == 0 || len(aliases) == 0 { + return args + } + + if args[0] == "help" && len(args) > 1 { + expanded, ok := resolveAlias(aliases, args[1]) + if !ok { + return args + } + + withHelp := make([]string, 0, 1+len(expanded)+len(args[2:])) + withHelp = append(withHelp, "help") + withHelp = append(withHelp, expanded...) + withHelp = append(withHelp, args[2:]...) + return withHelp + } + + expanded, ok := resolveAlias(aliases, args[0]) + if !ok { + return args + } + + withCommand := make([]string, 0, len(expanded)+len(args[1:])) + withCommand = append(withCommand, expanded...) + withCommand = append(withCommand, args[1:]...) + + if len(withCommand) == 0 { + return args + } + + last := strings.TrimSpace(withCommand[len(withCommand)-1]) + if last != "-h" && last != "--help" { + return withCommand + } + + helpArgs := make([]string, 0, len(withCommand)) + helpArgs = append(helpArgs, "help") + helpArgs = append(helpArgs, withCommand[:len(withCommand)-1]...) + return helpArgs +} + +func resolveAlias(aliases map[string][]string, command string) ([]string, bool) { + target, ok := aliases[strings.TrimSpace(command)] + if !ok { + return nil, false + } + + expanded := trimArgs(target) + if len(expanded) == 0 { + return nil, false + } + + return expanded, true +} + func trimArgs(args []string) []string { if len(args) == 0 { return nil diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index d3d34c1..356c76f 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -319,3 +319,82 @@ func TestRunConfigReturnsUnknownSubcommand(t *testing.T) { t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err) } } + +func TestRunRoutesAliasToConfigSubcommandHook(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + var got Invocation + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Aliases: map[string][]string{ + "doctor": {CommandConfig, ConfigSubcommandTest}, + }, + Args: []string{"doctor", "--profile", "work"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + ConfigTest: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != "config test" { + t.Fatalf("invocation command = %q, want %q", got.Command, "config test") + } + wantArgs := []string{"--profile", "work"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } +} + +func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Aliases: map[string][]string{ + "doctor": {CommandConfig, ConfigSubcommandTest}, + }, + Args: []string{"help", "doctor"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp config test [args]") { + t.Fatalf("command help output = %q", text) + } +} + +func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Aliases: map[string][]string{ + "doctor": {CommandConfig, ConfigSubcommandTest}, + }, + Args: []string{"doctor", "--help"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp config test [args]") { + t.Fatalf("command help output = %q", text) + } +} -- 2.45.2 From 26238eb31bc9bc835bfe826c5747483ee3ac8f38 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 16:40:50 +0200 Subject: [PATCH 14/79] feat(secretstore): add runtime manifest helper for backend opening --- README.md | 21 ++++- scaffold/scaffold.go | 7 +- scaffold/scaffold_test.go | 2 +- secretstore/manifest_open.go | 80 +++++++++++++++++ secretstore/manifest_open_test.go | 139 ++++++++++++++++++++++++++++++ secretstore/store.go | 33 ++++--- secretstore/store_test.go | 3 + 7 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 secretstore/manifest_open.go create mode 100644 secretstore/manifest_open_test.go diff --git a/README.md b/README.md index b3fb9b3..db1fb4d 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ go run ./cmd/my-mcp help - `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`. - `manifest` : lecture de `mcp.toml` à la racine du projet, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. - `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, wiring de base et README de démarrage). -- `secretstore` : lecture/écriture de secrets dans le wallet natif. +- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. ## Utilisation type @@ -288,7 +288,20 @@ Backends keyring typiques : - Linux : Secret Service ou KWallet selon l'environnement - Windows : Credential Manager -Exemple : +Ouverture recommandée depuis la policy du manifeste runtime (`mcp.toml` résolu +près de l'exécutable) : + +```go +store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + ServiceName: "my-mcp", + LookupEnv: os.LookupEnv, +}) +if err != nil { + return err +} +``` + +Exemple bas niveau : ```go store, err := secretstore.Open(secretstore.Options{ @@ -480,7 +493,7 @@ mais peut réutiliser les checks communs et ajouter ses propres hooks : report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ ConfigCheck: cli.NewConfigCheck(store), SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) { - return secretstore.Open(secretstore.Options{ + return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ ServiceName: "my-mcp", }) }), @@ -488,7 +501,7 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ {Name: "api-token", Label: "API token"}, }, SecretStoreFactory: func() (secretstore.Store, error) { - return secretstore.Open(secretstore.Options{ + return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ ServiceName: "my-mcp", }) }, diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 06b5da4..bd2c96e 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -377,7 +377,6 @@ type Runtime struct { ProfileEnv string TokenEnv string SecretName string - SecretStorePolicy string } func Run(ctx context.Context, args []string, version string) error { @@ -420,7 +419,6 @@ func NewRuntime(version string) (Runtime, error) { ProfileEnv: profileEnv, TokenEnv: tokenEnv, SecretName: binaryName + "-api-token", - SecretStorePolicy: firstNonEmpty(scaffoldInfo.SecretStorePolicy, "{{.SecretStorePolicy}}"), }, nil } @@ -609,9 +607,8 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error } func (r Runtime) openSecretStore() (secretstore.Store, error) { - return secretstore.Open(secretstore.Options{ - ServiceName: r.BinaryName, - BackendPolicy: secretstore.BackendPolicy(r.SecretStorePolicy), + return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + ServiceName: r.BinaryName, LookupEnv: func(name string) (string, bool) { if name == r.SecretName { return os.LookupEnv(r.TokenEnv) diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 3e65462..c166a3b 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -65,7 +65,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { } for _, snippet := range []string{ "config.NewStore[Profile]", - "secretstore.Open", + "secretstore.OpenFromManifest", "update.Run", "manifest.LoadDefault", "bootstrap.Run", diff --git a/secretstore/manifest_open.go b/secretstore/manifest_open.go new file mode 100644 index 0000000..71e0390 --- /dev/null +++ b/secretstore/manifest_open.go @@ -0,0 +1,80 @@ +package secretstore + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +type ManifestLoader func(startDir string) (manifest.File, string, error) + +type ExecutableResolver func() (string, error) + +type OpenFromManifestOptions struct { + ServiceName string + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + ManifestLoader ManifestLoader + ExecutableResolver ExecutableResolver +} + +func OpenFromManifest(options OpenFromManifestOptions) (Store, error) { + policy, err := resolveManifestBackendPolicy(options) + if err != nil { + return nil, err + } + + return Open(Options{ + ServiceName: options.ServiceName, + BackendPolicy: policy, + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + }) +} + +func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolicy, error) { + manifestLoader := options.ManifestLoader + if manifestLoader == nil { + manifestLoader = manifest.LoadDefault + } + + executableResolver := options.ExecutableResolver + if executableResolver == nil { + executableResolver = os.Executable + } + + executablePath, err := executableResolver() + if err != nil { + return "", fmt.Errorf("resolve executable path for manifest lookup: %w", err) + } + + startDir := filepath.Dir(strings.TrimSpace(executablePath)) + if strings.TrimSpace(startDir) == "" { + startDir = "." + } + + file, manifestPath, err := manifestLoader(startDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return BackendAuto, nil + } + return "", fmt.Errorf("load runtime manifest from %q: %w", startDir, err) + } + + if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" { + return BackendAuto, nil + } + + policy, err := normalizeBackendPolicy(BackendPolicy(file.SecretStore.BackendPolicy)) + if err != nil { + return "", fmt.Errorf("invalid secret_store.backend_policy in manifest %q: %w", strings.TrimSpace(manifestPath), err) + } + + return policy, nil +} diff --git a/secretstore/manifest_open_test.go b/secretstore/manifest_open_test.go new file mode 100644 index 0000000..bfe783a --- /dev/null +++ b/secretstore/manifest_open_test.go @@ -0,0 +1,139 @@ +package secretstore + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/99designs/keyring" + + "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) { + var gotStartDir string + + store, err := OpenFromManifest(OpenFromManifestOptions{ + ServiceName: "email-mcp", + LookupEnv: func(name string) (string, bool) { + if name == "EMAIL_TOKEN" { + return "from-env", true + } + return "", false + }, + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + gotStartDir = startDir + return manifest.File{ + SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)}, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("OpenFromManifest returned error: %v", err) + } + + wantDir := filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin") + if gotStartDir != wantDir { + t.Fatalf("manifest loader startDir = %q, want %q", gotStartDir, wantDir) + } + + value, err := store.GetSecret("EMAIL_TOKEN") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "from-env" { + t.Fatalf("GetSecret = %q, want from-env", value) + } +} + +func TestOpenFromManifestFallsBackToAutoWhenManifestIsMissing(t *testing.T) { + withKeyringHooks(t, nil, func(cfg keyring.Config) (keyring.Keyring, error) { + t.Fatal("unexpected keyring open call") + return nil, nil + }) + + store, err := OpenFromManifest(OpenFromManifestOptions{ + ServiceName: "email-mcp", + LookupEnv: func(name string) (string, bool) { + if name == "EMAIL_TOKEN" { + return "env-token", true + } + return "", false + }, + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(string) (manifest.File, string, error) { + return manifest.File{}, "", os.ErrNotExist + }, + }) + if err != nil { + t.Fatalf("OpenFromManifest returned error: %v", err) + } + + value, err := store.GetSecret("EMAIL_TOKEN") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "env-token" { + t.Fatalf("GetSecret = %q, want env-token", value) + } +} + +func TestOpenFromManifestReturnsExplicitErrorForInvalidManifestPolicy(t *testing.T) { + _, err := OpenFromManifest(OpenFromManifestOptions{ + ServiceName: "email-mcp", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{BackendPolicy: "totally-invalid"}, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrInvalidBackendPolicy) { + t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err) + } + if !strings.Contains(err.Error(), "secret_store.backend_policy") { + t.Fatalf("error = %v, want manifest policy context", err) + } + if !strings.Contains(err.Error(), manifest.DefaultFile) { + t.Fatalf("error = %v, want manifest path", err) + } +} + +func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) { + execErr := errors.New("boom") + _, err := OpenFromManifest(OpenFromManifestOptions{ + ExecutableResolver: func() (string, error) { + return "", execErr + }, + }) + if !errors.Is(err, execErr) { + t.Fatalf("error = %v, want wrapped executable resolver error", err) + } +} + +func TestOpenFromManifestReturnsManifestLoaderError(t *testing.T) { + loadErr := errors.New("cannot parse manifest") + _, err := OpenFromManifest(OpenFromManifestOptions{ + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(string) (manifest.File, string, error) { + return manifest.File{}, "", loadErr + }, + }) + if !errors.Is(err, loadErr) { + t.Fatalf("error = %v, want wrapped manifest loader error", err) + } +} diff --git a/secretstore/store.go b/secretstore/store.go index 3cb7e6f..1cd61d1 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -15,6 +15,7 @@ import ( var ErrNotFound = errors.New("secret not found") var ErrBackendUnavailable = errors.New("secret backend unavailable") var ErrReadOnly = errors.New("secret backend is read-only") +var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy") type BackendPolicy string @@ -77,15 +78,9 @@ var ( ) func Open(options Options) (Store, error) { - policy := options.BackendPolicy - if policy == "" { - policy = BackendAuto - } - - switch policy { - case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly: - default: - return nil, fmt.Errorf("invalid secret backend policy %q", policy) + policy, err := normalizeBackendPolicy(options.BackendPolicy) + if err != nil { + return nil, err } if policy == BackendEnvOnly { @@ -249,7 +244,7 @@ func allowedBackends(policy BackendPolicy, available []keyring.BackendType) ([]k } return []keyring.BackendType{keyring.KWalletBackend}, nil default: - return nil, fmt.Errorf("invalid secret backend policy %q", policy) + return nil, invalidBackendPolicyError(policy) } } @@ -260,3 +255,21 @@ func backendNames(backends []keyring.BackendType) []string { } return names } + +func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) { + trimmed := BackendPolicy(strings.TrimSpace(string(policy))) + if trimmed == "" { + return BackendAuto, nil + } + + switch trimmed { + case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly: + return trimmed, nil + default: + return "", invalidBackendPolicyError(trimmed) + } +} + +func invalidBackendPolicyError(policy BackendPolicy) error { + return fmt.Errorf("%w %q", ErrInvalidBackendPolicy, policy) +} diff --git a/secretstore/store_test.go b/secretstore/store_test.go index c7a2f68..818ea41 100644 --- a/secretstore/store_test.go +++ b/secretstore/store_test.go @@ -80,6 +80,9 @@ func TestOpenRejectsInvalidPolicy(t *testing.T) { if err == nil { t.Fatal("expected error") } + if !errors.Is(err, ErrInvalidBackendPolicy) { + t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err) + } } func TestOpenAutoUsesAvailableKeyringBackends(t *testing.T) { -- 2.45.2 From ea1768982eb576081047b9204a33763dbb985105 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 16:48:26 +0200 Subject: [PATCH 15/79] feat(cli): add multi-source resolve lookup helper --- README.md | 36 +++------ cli/resolve_lookup.go | 87 ++++++++++++++++++++++ cli/resolve_lookup_test.go | 145 +++++++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 24 deletions(-) create mode 100644 cli/resolve_lookup.go create mode 100644 cli/resolve_lookup_test.go diff --git a/README.md b/README.md index db1fb4d..942c8cd 100644 --- a/README.md +++ b/README.md @@ -439,32 +439,20 @@ Les validations sont appliquées de manière cohérente en TTY et en stdin non i Pour standardiser la résolution `flag > env > config > secret` avec provenance : ```go -lookup := func(source cli.ValueSource, key string) (string, bool, error) { - switch source { - case cli.SourceFlag: - value, ok := flagValues[key] - return value, ok, nil - case cli.SourceEnv: - value, ok := os.LookupEnv(key) - return value, ok, nil - case cli.SourceConfig: - value, ok := configValues[key] - return value, ok, nil - case cli.SourceSecret: - value, err := store.GetSecret(key) - switch { - case err == nil: - return value, true, nil - case errors.Is(err, secretstore.ErrNotFound): - return "", false, nil - default: - return "", false, err - } - default: - return "", false, nil - } +store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + ServiceName: "my-mcp", +}) +if err != nil { + return err } +lookup := cli.ResolveLookup(cli.ResolveLookupOptions{ + Flag: cli.MapLookup(flagValues), + Env: cli.EnvLookup(os.LookupEnv), + Config: cli.ConfigMap(configValues), + Secret: cli.SecretStore(store), // ErrNotFound => valeur absente, pas une erreur +}) + resolution, err := cli.ResolveFields(cli.ResolveOptions{ Fields: []cli.FieldSpec{ {Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"}, diff --git a/cli/resolve_lookup.go b/cli/resolve_lookup.go new file mode 100644 index 0000000..f42fc45 --- /dev/null +++ b/cli/resolve_lookup.go @@ -0,0 +1,87 @@ +package cli + +import ( + "errors" + "os" + + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type KeyLookupFunc func(key string) (string, bool, error) + +type ResolveLookupOptions struct { + Flag KeyLookupFunc + Env KeyLookupFunc + Config KeyLookupFunc + Secret KeyLookupFunc +} + +func ResolveLookup(options ResolveLookupOptions) LookupFunc { + return func(source ValueSource, key string) (string, bool, error) { + switch source { + case SourceFlag: + return runKeyLookup(options.Flag, key) + case SourceEnv: + return runKeyLookup(options.Env, key) + case SourceConfig: + return runKeyLookup(options.Config, key) + case SourceSecret: + return runKeyLookup(options.Secret, key) + case SourceDefault: + return "", false, nil + default: + return "", false, nil + } + } +} + +func runKeyLookup(lookup KeyLookupFunc, key string) (string, bool, error) { + if lookup == nil { + return "", false, nil + } + if key == "" { + return "", false, nil + } + return lookup(key) +} + +func MapLookup(values map[string]string) KeyLookupFunc { + return func(key string) (string, bool, error) { + value, ok := values[key] + return value, ok, nil + } +} + +func EnvLookup(lookup func(string) (string, bool)) KeyLookupFunc { + reader := lookup + if reader == nil { + reader = os.LookupEnv + } + + return func(key string) (string, bool, error) { + value, ok := reader(key) + return value, ok, nil + } +} + +func ConfigMap(values map[string]string) KeyLookupFunc { + return MapLookup(values) +} + +func SecretStore(store secretstore.Store) KeyLookupFunc { + if store == nil { + return nil + } + + return func(key string) (string, bool, error) { + value, err := store.GetSecret(key) + switch { + case err == nil: + return value, true, nil + case errors.Is(err, secretstore.ErrNotFound): + return "", false, nil + default: + return "", false, err + } + } +} diff --git a/cli/resolve_lookup_test.go b/cli/resolve_lookup_test.go new file mode 100644 index 0000000..32e4d23 --- /dev/null +++ b/cli/resolve_lookup_test.go @@ -0,0 +1,145 @@ +package cli + +import ( + "errors" + "testing" + + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type testSecretStore struct { + values map[string]string + errs map[string]error +} + +func (s testSecretStore) SetSecret(name, label, secret string) error { + return nil +} + +func (s testSecretStore) GetSecret(name string) (string, error) { + if err, ok := s.errs[name]; ok { + return "", err + } + value, ok := s.values[name] + if !ok { + return "", secretstore.ErrNotFound + } + return value, nil +} + +func (s testSecretStore) DeleteSecret(name string) error { + return nil +} + +func TestResolveLookupWithStandardProviders(t *testing.T) { + t.Setenv("MCP_PASSWORD", "") + + lookup := ResolveLookup(ResolveLookupOptions{ + Flag: MapLookup(map[string]string{"host": "https://flag.example.com"}), + Env: EnvLookup(nil), + Config: ConfigMap(map[string]string{"username": "config-user"}), + Secret: SecretStore(testSecretStore{ + values: map[string]string{"smtp-password": "secret-password"}, + }), + }) + + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "host", + Required: true, + FlagKey: "host", + }, + { + Name: "username", + Required: true, + EnvKey: "MCP_USERNAME", + ConfigKey: "username", + }, + { + Name: "password", + Required: true, + EnvKey: "MCP_PASSWORD", + SecretKey: "smtp-password", + }, + }, + Lookup: lookup, + }) + if err != nil { + t.Fatalf("ResolveFields returned error: %v", err) + } + + host, _ := resolution.Get("host") + if host.Source != SourceFlag || host.Value != "https://flag.example.com" { + t.Fatalf("host = %+v", host) + } + + username, _ := resolution.Get("username") + if username.Source != SourceConfig || username.Value != "config-user" { + t.Fatalf("username = %+v", username) + } + + password, _ := resolution.Get("password") + if password.Source != SourceSecret || password.Value != "secret-password" { + t.Fatalf("password = %+v", password) + } +} + +func TestSecretStoreProviderTreatsErrNotFoundAsMissing(t *testing.T) { + lookup := ResolveLookup(ResolveLookupOptions{ + Secret: SecretStore(testSecretStore{ + errs: map[string]error{"smtp-password": secretstore.ErrNotFound}, + }), + }) + + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "password", + Required: true, + SecretKey: "smtp-password", + DefaultValue: "fallback-password", + }, + }, + Lookup: lookup, + }) + if err != nil { + t.Fatalf("ResolveFields returned error: %v", err) + } + + password, _ := resolution.Get("password") + if password.Source != SourceDefault || password.Value != "fallback-password" { + t.Fatalf("password = %+v", password) + } +} + +func TestSecretStoreProviderPropagatesBackendErrors(t *testing.T) { + backendErr := errors.New("backend unavailable") + lookup := ResolveLookup(ResolveLookupOptions{ + Secret: SecretStore(testSecretStore{ + errs: map[string]error{"smtp-password": backendErr}, + }), + }) + + _, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "password", + Required: true, + SecretKey: "smtp-password", + }, + }, + Lookup: lookup, + }) + + var sourceErr *SourceLookupError + if !errors.As(err, &sourceErr) { + t.Fatalf("ResolveFields error = %v, want SourceLookupError", err) + } + if sourceErr.Source != SourceSecret { + t.Fatalf("sourceErr.Source = %q, want %q", sourceErr.Source, SourceSecret) + } + if !errors.Is(err, backendErr) { + t.Fatalf("ResolveFields error should wrap backend error") + } +} -- 2.45.2 From 3d8a7dc84dc5a0bbda46883a4d1a8a0a6f6a400e Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 16:53:26 +0200 Subject: [PATCH 16/79] feat(cli): add reusable doctor check for resolved profile fields --- README.md | 30 ++++++++++++ cli/doctor.go | 114 +++++++++++++++++++++++++++++++++++++++++++++ cli/doctor_test.go | 112 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+) diff --git a/README.md b/README.md index 942c8cd..a713318 100644 --- a/README.md +++ b/README.md @@ -520,6 +520,36 @@ if report.HasFailures() { } ``` +Pour éviter des checks custom répétitifs sur les profils, `doctor` expose aussi +un helper basé sur `FieldSpec` : + +```go +report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ + ExtraChecks: []cli.DoctorCheck{ + cli.RequiredResolvedFieldsCheck(cli.ResolveOptions{ + Fields: []cli.FieldSpec{ + {Name: "base_url", Required: true, EnvKey: "MY_MCP_BASE_URL"}, + { + Name: "api_token", + Required: true, + EnvKey: "MY_MCP_API_TOKEN", + SecretKey: "my-mcp-api-token", + Sources: []cli.ValueSource{cli.SourceEnv, cli.SourceSecret}, + }, + }, + Lookup: cli.ResolveLookup(cli.ResolveLookupOptions{ + Env: cli.EnvLookup(os.LookupEnv), + Config: cli.ConfigMap(configValues), + Secret: cli.SecretStore(secretStore), // provenance explicite: env ou secret + }), + }), + }, +}) +``` + +En cas d'échec de résolution, tu peux aussi réutiliser le formatteur +`cli.FormatResolveFieldsError(err)` dans un check custom pour garder des messages homogènes. + ## Auto-Update Le package `update` supporte les drivers `gitea`, `gitlab` et `github`. diff --git a/cli/doctor.go b/cli/doctor.go index ae2cdd9..8dfca94 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -270,6 +270,82 @@ func RequiredSecretsCheck(factory func() (secretstore.Store, error), required [] } } +func RequiredResolvedFieldsCheck(options ResolveOptions) DoctorCheck { + required := requiredFieldNames(options.Fields) + + return func(context.Context) DoctorResult { + if len(required) == 0 { + return DoctorResult{ + Name: "required-profile-fields", + Status: DoctorStatusWarn, + Summary: "no required profile field is configured", + } + } + + resolution, err := ResolveFields(options) + if err != nil { + return DoctorResult{ + Name: "required-profile-fields", + Status: DoctorStatusFail, + Summary: resolveFieldsErrorSummary(err), + Detail: FormatResolveFieldsError(err), + } + } + + sources := make([]string, 0, len(required)) + for _, name := range required { + field, ok := resolution.Get(name) + if !ok || !field.Found { + return DoctorResult{ + Name: "required-profile-fields", + Status: DoctorStatusFail, + Summary: "required profile values are missing", + Detail: name, + } + } + sources = append(sources, fmt.Sprintf("%s=%s", name, field.Source)) + } + + return DoctorResult{ + Name: "required-profile-fields", + Status: DoctorStatusOK, + Summary: "required profile values are resolved", + Detail: strings.Join(sources, ", "), + } + } +} + +func FormatResolveFieldsError(err error) string { + if err == nil { + return "" + } + + var missing *MissingRequiredValuesError + if errors.As(err, &missing) { + if len(missing.Fields) == 0 { + return "missing required configuration values" + } + return strings.Join(missing.Fields, ", ") + } + + var lookupErr *SourceLookupError + if errors.As(err, &lookupErr) { + key := strings.TrimSpace(lookupErr.Key) + if key == "" { + key = lookupErr.Field + } + return fmt.Sprintf( + "field %q via source %q (key %q): %v", + lookupErr.Field, + lookupErr.Source, + key, + lookupErr.Err, + ) + } + + return err.Error() +} + func ManifestCheck(startDir string, validator DoctorManifestValidator) DoctorCheck { return func(context.Context) DoctorResult { file, path, err := manifest.LoadDefault(startDir) @@ -334,3 +410,41 @@ func doctorLabel(status DoctorStatus) string { return "FAIL" } } + +func requiredFieldNames(specs []FieldSpec) []string { + required := make([]string, 0, len(specs)) + seen := make(map[string]struct{}, len(specs)) + + for _, spec := range specs { + if !spec.Required { + continue + } + + name := strings.TrimSpace(spec.Name) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + + seen[name] = struct{}{} + required = append(required, name) + } + + return required +} + +func resolveFieldsErrorSummary(err error) string { + var missing *MissingRequiredValuesError + if errors.As(err, &missing) { + return "required profile values are missing" + } + + var lookupErr *SourceLookupError + if errors.As(err, &lookupErr) { + return fmt.Sprintf("cannot resolve profile value %q", lookupErr.Field) + } + + return "profile resolution failed" +} diff --git a/cli/doctor_test.go b/cli/doctor_test.go index 61ac464..04dc23f 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -171,3 +171,115 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) { t.Fatalf("detail = %q", result.Detail) } } + +func TestRequiredResolvedFieldsCheckReportsSources(t *testing.T) { + check := RequiredResolvedFieldsCheck(ResolveOptions{ + Fields: []FieldSpec{ + {Name: "base_url", Required: true, ConfigKey: "base_url"}, + { + Name: "api_token", + Required: true, + EnvKey: "MY_API_TOKEN", + SecretKey: "my-api-token", + Sources: []ValueSource{SourceEnv, SourceSecret}, + }, + }, + Lookup: ResolveLookup(ResolveLookupOptions{ + Env: MapLookup(map[string]string{"MY_API_TOKEN": "env-token"}), + Config: ConfigMap(map[string]string{"base_url": "https://api.example.com"}), + Secret: MapLookup(map[string]string{"my-api-token": "secret-token"}), + }), + }) + + result := check(context.Background()) + if result.Status != DoctorStatusOK { + t.Fatalf("status = %q, want ok", result.Status) + } + for _, needle := range []string{"base_url=config", "api_token=env"} { + if !strings.Contains(result.Detail, needle) { + t.Fatalf("detail = %q, want substring %q", result.Detail, needle) + } + } +} + +func TestRequiredResolvedFieldsCheckUsesSecretAsFallback(t *testing.T) { + check := RequiredResolvedFieldsCheck(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "api_token", + Required: true, + EnvKey: "MY_API_TOKEN", + SecretKey: "my-api-token", + Sources: []ValueSource{SourceEnv, SourceSecret}, + }, + }, + Lookup: ResolveLookup(ResolveLookupOptions{ + Env: MapLookup(map[string]string{}), + Secret: MapLookup(map[string]string{"my-api-token": "secret-token"}), + }), + }) + + result := check(context.Background()) + if result.Status != DoctorStatusOK { + t.Fatalf("status = %q, want ok", result.Status) + } + if !strings.Contains(result.Detail, "api_token=secret") { + t.Fatalf("detail = %q, want secret provenance", result.Detail) + } +} + +func TestRequiredResolvedFieldsCheckFailsWithMissingRequiredField(t *testing.T) { + check := RequiredResolvedFieldsCheck(ResolveOptions{ + Fields: []FieldSpec{ + {Name: "base_url", Required: true}, + }, + Lookup: ResolveLookup(ResolveLookupOptions{ + Env: MapLookup(map[string]string{}), + }), + }) + + result := check(context.Background()) + if result.Status != DoctorStatusFail { + t.Fatalf("status = %q, want fail", result.Status) + } + if result.Summary != "required profile values are missing" { + t.Fatalf("summary = %q", result.Summary) + } + if result.Detail != "base_url" { + t.Fatalf("detail = %q, want base_url", result.Detail) + } +} + +func TestRequiredResolvedFieldsCheckFormatsLookupErrors(t *testing.T) { + lookupErr := errors.New("vault unavailable") + check := RequiredResolvedFieldsCheck(ResolveOptions{ + Fields: []FieldSpec{ + {Name: "api_token", Required: true, Sources: []ValueSource{SourceSecret}}, + }, + Lookup: func(source ValueSource, key string) (string, bool, error) { + if source == SourceSecret { + return "", false, lookupErr + } + return "", false, nil + }, + }) + + result := check(context.Background()) + if result.Status != DoctorStatusFail { + t.Fatalf("status = %q, want fail", result.Status) + } + if result.Summary != "cannot resolve profile value \"api_token\"" { + t.Fatalf("summary = %q", result.Summary) + } + for _, needle := range []string{"api_token", "source \"secret\"", "key \"api_token\"", "vault unavailable"} { + if !strings.Contains(result.Detail, needle) { + t.Fatalf("detail = %q, want substring %q", result.Detail, needle) + } + } +} + +func TestFormatResolveFieldsErrorWithNilError(t *testing.T) { + if got := FormatResolveFieldsError(nil); got != "" { + t.Fatalf("FormatResolveFieldsError(nil) = %q, want empty string", got) + } +} -- 2.45.2 From 0e5bfb2d390143ec86810667c1e1169a733abeb5 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 17:51:28 +0200 Subject: [PATCH 17/79] feat(bootstrap): expose doctor alias in help and options --- README.md | 11 ++-- bootstrap/bootstrap.go | 103 ++++++++++++++++++++++++++++++++---- bootstrap/bootstrap_test.go | 84 +++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a713318..f9145a8 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,13 @@ Exemple minimal : ```go func main() { err := bootstrap.Run(context.Background(), bootstrap.Options{ - BinaryName: "my-mcp", - Description: "Client MCP", - Version: version, + BinaryName: "my-mcp", + Description: "Client MCP", + Version: version, + EnableDoctorAlias: true, // expose `doctor` comme alias de `config test` + AliasDescriptions: map[string]string{ + "doctor": "Diagnostiquer la configuration locale.", + }, Hooks: bootstrap.Hooks{ Setup: func(ctx context.Context, inv bootstrap.Invocation) error { return runSetup(ctx, inv.Args) @@ -102,6 +106,7 @@ automatiquement `Options.Version`. Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). +Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. ## Manifeste `mcp.toml` diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 4d5521b..ea2ff2f 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "sort" "strings" ) @@ -15,6 +16,7 @@ const ( CommandConfig = "config" CommandUpdate = "update" CommandVersion = "version" + CommandDoctor = "doctor" ConfigSubcommandShow = "show" ConfigSubcommandTest = "test" @@ -44,15 +46,17 @@ type Hooks struct { } type Options struct { - BinaryName string - Description string - Version string - Aliases map[string][]string - Args []string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - Hooks Hooks + BinaryName string + Description string + Version string + Aliases map[string][]string + AliasDescriptions map[string]string + EnableDoctorAlias bool + Args []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Hooks Hooks } type Invocation struct { @@ -165,9 +169,62 @@ func normalize(opts Options) Options { if opts.Args == nil { opts.Args = os.Args[1:] } + opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias) + opts.AliasDescriptions = normalizeAliasDescriptions(opts.AliasDescriptions, opts.Aliases, opts.EnableDoctorAlias) return opts } +func normalizeAliases(aliases map[string][]string, enableDoctorAlias bool) map[string][]string { + normalized := make(map[string][]string, len(aliases)+1) + for name, target := range aliases { + trimmedName := strings.TrimSpace(name) + trimmedTarget := trimArgs(target) + if trimmedName == "" || len(trimmedTarget) == 0 { + continue + } + normalized[trimmedName] = trimmedTarget + } + + if enableDoctorAlias { + if _, ok := normalized[CommandDoctor]; !ok { + normalized[CommandDoctor] = []string{CommandConfig, ConfigSubcommandTest} + } + } + + if len(normalized) == 0 { + return nil + } + return normalized +} + +func normalizeAliasDescriptions(descriptions map[string]string, aliases map[string][]string, enableDoctorAlias bool) map[string]string { + normalized := make(map[string]string, len(descriptions)+1) + for name, description := range descriptions { + trimmedName := strings.TrimSpace(name) + trimmedDescription := strings.TrimSpace(description) + if trimmedName == "" || trimmedDescription == "" { + continue + } + if _, ok := aliases[trimmedName]; !ok { + continue + } + normalized[trimmedName] = trimmedDescription + } + + if enableDoctorAlias { + if _, ok := aliases[CommandDoctor]; ok { + if _, defined := normalized[CommandDoctor]; !defined { + normalized[CommandDoctor] = "Diagnostiquer la configuration locale." + } + } + } + + if len(normalized) == 0 { + return nil + } + return normalized +} + func parseArgs(args []string) (command string, commandArgs []string, showHelp bool) { args = trimArgs(args) if len(args) == 0 { @@ -435,6 +492,34 @@ func printGlobalHelp(opts Options) error { } } + if len(opts.Aliases) > 0 { + if _, err := fmt.Fprintln(opts.Stdout, "\nAlias:"); err != nil { + return err + } + + names := make([]string, 0, len(opts.Aliases)) + for name := range opts.Aliases { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + target := strings.Join(opts.Aliases[name], " ") + description := aliasDescription(opts.AliasDescriptions, name, target) + if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", name, description); err != nil { + return err + } + } + } + _, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help \n", opts.BinaryName) return err } + +func aliasDescription(descriptions map[string]string, name, target string) string { + description := strings.TrimSpace(descriptions[name]) + if description == "" { + return fmt.Sprintf("Alias de %q.", target) + } + return fmt.Sprintf("%s (alias de %q).", description, target) +} diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index 356c76f..02644e3 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -398,3 +398,87 @@ func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) { t.Fatalf("command help output = %q", text) } } + +func TestRunPrintsAliasesInGlobalHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Aliases: map[string][]string{ + "doctor": {CommandConfig, ConfigSubcommandTest}, + }, + AliasDescriptions: map[string]string{ + "doctor": "Diagnostiquer la configuration locale.", + }, + Args: []string{"help"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "Alias:") { + t.Fatalf("global help output missing alias section: %q", text) + } + if !strings.Contains(text, `doctor Diagnostiquer la configuration locale. (alias de "config test").`) { + t.Fatalf("global help output missing doctor alias details: %q", text) + } +} + +func TestRunRoutesDoctorAliasWhenEnabled(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + var got Invocation + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + EnableDoctorAlias: true, + Args: []string{"doctor", "--profile", "prod"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + ConfigTest: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != "config test" { + t.Fatalf("invocation command = %q, want %q", got.Command, "config test") + } + wantArgs := []string{"--profile", "prod"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } +} + +func TestRunPrintsDoctorAliasInGlobalHelpWhenEnabled(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + EnableDoctorAlias: true, + Args: []string{"help"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "Alias:") { + t.Fatalf("global help output missing alias section: %q", text) + } + if !strings.Contains(text, `doctor Diagnostiquer la configuration locale. (alias de "config test").`) { + t.Fatalf("global help output missing default doctor alias details: %q", text) + } +} -- 2.45.2 From 845d20541b5f667ed7305d5045e025fd9e4b8af8 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 18:02:07 +0200 Subject: [PATCH 18/79] feat: add unified build command driven by mcp.toml --- README.md | 34 +++++ cmd/mcp-framework/build.go | 234 ++++++++++++++++++++++++++++++++ cmd/mcp-framework/build_test.go | 155 +++++++++++++++++++++ cmd/mcp-framework/main.go | 4 +- cmd/mcp-framework/main_test.go | 3 + manifest/manifest.go | 14 ++ manifest/manifest_test.go | 14 ++ scaffold/scaffold.go | 5 + scaffold/scaffold_test.go | 2 + 9 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 cmd/mcp-framework/build.go create mode 100644 cmd/mcp-framework/build_test.go diff --git a/README.md b/README.md index f9145a8..18502d8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,36 @@ go mod tidy go run ./cmd/my-mcp help ``` +## CLI de build unifiée + +Le binaire `mcp-framework` expose aussi une commande de build standardisée pour +les projets consommateurs : + +```bash +go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build +``` + +Par défaut la commande : + +- lit `mcp.toml` (en remontant les répertoires parents) +- récupère `binary_name` +- build `./cmd/` +- produit `build/--{.exe}` +- injecte la version via `-X main.version=` + (`VERSION` env, sinon `git describe`, sinon `dev`) + +Options principales : + +- `--manifest-dir` +- `--binary` +- `--package` +- `--build-dir` +- `--goos` +- `--goarch` +- `--version` +- `--version-var` +- `--ldflag` (répétable) + ## Packages - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. @@ -150,6 +180,7 @@ 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`. +- `[build]` : paramètres de build pour `mcp-framework build`. - `source_name` : nom humain de la source de release, utilisé dans certains messages d'erreur. - `driver` : driver de forge (`gitea`, `gitlab`, `github`) pour déduire automatiquement l'endpoint latest. @@ -162,6 +193,9 @@ Champs supportés : - `token_header` : header HTTP à utiliser pour l'authentification. - `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...). - `token_env_names` : liste de variables d'environnement candidates pour retrouver le token. +- `[build].main_package` : package main à builder (ex: `./cmd/my-mcp`). +- `[build].output_dir` : répertoire de sortie des artefacts (défaut `build`). +- `[build].version_var` : variable Go ciblée par `-X` pour injecter la version (`main.version`, `gitlab.../internal/app.Version`, etc.). - `[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. diff --git a/cmd/mcp-framework/build.go b/cmd/mcp-framework/build.go new file mode 100644 index 0000000..de70f8d --- /dev/null +++ b/cmd/mcp-framework/build.go @@ -0,0 +1,234 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + manifestpkg "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +type stringListFlag []string + +func (f *stringListFlag) String() string { + return strings.Join(*f, ",") +} + +func (f *stringListFlag) Set(value string) error { + *f = append(*f, strings.TrimSpace(value)) + return nil +} + +func runBuild(args []string, stdout, stderr io.Writer) error { + if shouldShowHelp(args) { + printBuildHelp(stdout) + return nil + } + + fs := flag.NewFlagSet("build", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + defaultGOOS := strings.TrimSpace(os.Getenv("GOOS")) + if defaultGOOS == "" { + defaultGOOS = runtime.GOOS + } + + defaultGOARCH := strings.TrimSpace(os.Getenv("GOARCH")) + if defaultGOARCH == "" { + defaultGOARCH = runtime.GOARCH + } + + var manifestDir string + var binaryName string + var mainPackage string + var buildDir string + var goos string + var goarch string + var version string + var versionVar string + var gocache string + var extraLDFlags stringListFlag + + fs.StringVar(&manifestDir, "manifest-dir", ".", "Répertoire de départ pour trouver mcp.toml") + fs.StringVar(&binaryName, "binary", "", "Nom du binaire (override binary_name)") + fs.StringVar(&mainPackage, "package", "", "Package main à builder (override [build].main_package)") + fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") + fs.StringVar(&goos, "goos", defaultGOOS, "GOOS cible") + fs.StringVar(&goarch, "goarch", defaultGOARCH, "GOARCH cible") + fs.StringVar(&version, "version", "", "Version injectée (par défaut VERSION env, puis git describe, puis dev)") + fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") + fs.StringVar(&gocache, "gocache", strings.TrimSpace(os.Getenv("GOCACHE")), "Valeur GOCACHE pour go build") + fs.Var(&extraLDFlags, "ldflag", "Option additionnelle passée à -ldflags (répéter si nécessaire)") + + if err := fs.Parse(args); err != nil { + _ = stderr + return fmt.Errorf("parse build flags: %w", err) + } + + if fs.NArg() > 0 { + return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) + } + + file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) + if err != nil { + return err + } + + projectDir := filepath.Dir(manifestPath) + + binaryName = firstNonEmpty(binaryName, file.BinaryName) + if binaryName == "" { + return errors.New("binary name is required (set binary_name in mcp.toml or --binary)") + } + + mainPackage = firstNonEmpty(mainPackage, file.Build.MainPackage) + if mainPackage == "" { + mainPackage = fmt.Sprintf("./cmd/%s", binaryName) + } + + buildDir = firstNonEmpty(buildDir, file.Build.OutputDir, "build") + goos = firstNonEmpty(goos, runtime.GOOS) + goarch = firstNonEmpty(goarch, runtime.GOARCH) + version = resolveBuildVersion(version, projectDir) + versionVar = firstNonEmpty(versionVar, file.Build.VersionVar, "main.version") + + outputPath, err := buildOutputPath(projectDir, buildDir, binaryName, goos, goarch) + if err != nil { + return err + } + + outputDir := filepath.Dir(outputPath) + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("create build directory %q: %w", outputDir, err) + } + + ldflags := normalizeStringList(extraLDFlags) + if versionVar != "-" { + versionVar = strings.TrimSpace(versionVar) + if versionVar != "" { + ldflags = append([]string{fmt.Sprintf("-X %s=%s", versionVar, version)}, ldflags...) + } + } + + cmdArgs := []string{"build"} + if len(ldflags) > 0 { + cmdArgs = append(cmdArgs, "-ldflags", strings.Join(ldflags, " ")) + } + cmdArgs = append(cmdArgs, "-o", outputPath, mainPackage) + + cmd := exec.Command("go", cmdArgs...) + cmd.Dir = projectDir + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Env = withEnvOverrides(os.Environ(), map[string]string{ + "GOOS": goos, + "GOARCH": goarch, + "GOCACHE": strings.TrimSpace(gocache), + }) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("go build failed: %w", err) + } + + if _, err := fmt.Fprintf(stdout, "Build artifact: %s\n", outputPath); err != nil { + return err + } + + return nil +} + +func printBuildHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s build [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n", + toolName, + ) +} + +func resolveBuildVersion(explicit, projectDir string) string { + if value := strings.TrimSpace(explicit); value != "" { + return value + } + if value := strings.TrimSpace(os.Getenv("VERSION")); value != "" { + return value + } + + describe := exec.Command("git", "describe", "--tags", "--always", "--dirty") + describe.Dir = projectDir + out, err := describe.Output() + if err == nil { + if value := strings.TrimSpace(string(out)); value != "" { + return value + } + } + + return "dev" +} + +func buildOutputPath(projectDir, buildDir, binaryName, goos, goarch string) (string, error) { + artifactName := fmt.Sprintf("%s-%s-%s", binaryName, goos, goarch) + if goos == "windows" { + artifactName += ".exe" + } + + dir := strings.TrimSpace(buildDir) + if dir == "" { + dir = "build" + } + + if filepath.IsAbs(dir) { + return filepath.Join(dir, artifactName), nil + } + if strings.TrimSpace(projectDir) == "" { + return "", errors.New("project directory is required") + } + return filepath.Join(projectDir, dir, artifactName), nil +} + +func withEnvOverrides(base []string, overrides map[string]string) []string { + result := make([]string, 0, len(base)+len(overrides)) + for _, entry := range base { + key, _, found := strings.Cut(entry, "=") + if !found { + continue + } + if _, overridden := overrides[key]; overridden { + continue + } + result = append(result, entry) + } + + for key, value := range overrides { + if strings.TrimSpace(value) == "" { + continue + } + result = append(result, key+"="+value) + } + + return result +} + +func normalizeStringList(values []string) []string { + normalized := make([]string, 0, len(values)) + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + normalized = append(normalized, trimmed) + } + } + return normalized +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/cmd/mcp-framework/build_test.go b/cmd/mcp-framework/build_test.go new file mode 100644 index 0000000..5fa1fd4 --- /dev/null +++ b/cmd/mcp-framework/build_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestRunBuildBuildsArtifactFromManifest(t *testing.T) { + projectDir := t.TempDir() + writeBuildFixture(t, projectDir, "demo-mcp", ` +module example.com/demo + +go 1.25.0 +`, ` +binary_name = "demo-mcp" + +[build] +main_package = "./cmd/demo-mcp" +output_dir = "build" +version_var = "main.version" +`, ` +package main + +import "fmt" + +var version = "dev" + +func main() { + fmt.Print(version) +} +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run([]string{ + "build", + "--manifest-dir", projectDir, + "--goos", runtime.GOOS, + "--goarch", runtime.GOARCH, + "--version", "1.2.3", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) + } + + artifactPath := artifactPath(projectDir, "build", "demo-mcp", runtime.GOOS, runtime.GOARCH) + if _, err := os.Stat(artifactPath); err != nil { + t.Fatalf("expected artifact at %s: %v", artifactPath, err) + } + + output, err := exec.Command(artifactPath).Output() + if err != nil { + t.Fatalf("run artifact: %v", err) + } + if strings.TrimSpace(string(output)) != "1.2.3" { + t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "1.2.3") + } +} + +func TestRunBuildUsesManifestVersionVar(t *testing.T) { + projectDir := t.TempDir() + writeBuildFixture(t, projectDir, "custom-version", ` +module example.com/custom-version + +go 1.25.0 +`, ` +binary_name = "custom-version" + +[build] +main_package = "./cmd/custom-version" +output_dir = "dist" +version_var = "main.releaseVersion" +`, ` +package main + +import "fmt" + +var releaseVersion = "dev" + +func main() { + fmt.Print(releaseVersion) +} +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run([]string{ + "build", + "--manifest-dir", projectDir, + "--goos", runtime.GOOS, + "--goarch", runtime.GOARCH, + "--version", "9.9.9", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) + } + + artifactPath := artifactPath(projectDir, "dist", "custom-version", runtime.GOOS, runtime.GOARCH) + output, err := exec.Command(artifactPath).Output() + if err != nil { + t.Fatalf("run artifact: %v", err) + } + if strings.TrimSpace(string(output)) != "9.9.9" { + t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "9.9.9") + } +} + +func TestBuildHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + if err := run([]string{"build", "--help"}, &stdout, &stderr); err != nil { + t.Fatalf("run returned error: %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "--manifest-dir") { + t.Fatalf("build help should mention --manifest-dir: %q", output) + } + if !strings.Contains(output, "version_var") { + t.Fatalf("build help should mention manifest build config: %q", output) + } +} + +func writeBuildFixture(t *testing.T, projectDir, binaryName, goModContent, manifestContent, mainContent string) { + t.Helper() + + if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(strings.TrimSpace(goModContent)+"\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(strings.TrimSpace(manifestContent)+"\n"), 0o644); err != nil { + t.Fatalf("write mcp.toml: %v", err) + } + + cmdDir := filepath.Join(projectDir, "cmd", binaryName) + if err := os.MkdirAll(cmdDir, 0o755); err != nil { + t.Fatalf("mkdir cmd dir: %v", err) + } + if err := os.WriteFile(filepath.Join(cmdDir, "main.go"), []byte(strings.TrimSpace(mainContent)+"\n"), 0o644); err != nil { + t.Fatalf("write main.go: %v", err) + } +} + +func artifactPath(projectDir, outDir, binaryName, goos, goarch string) string { + name := binaryName + "-" + goos + "-" + goarch + if goos == "windows" { + name += ".exe" + } + return filepath.Join(projectDir, outDir, name) +} diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go index d6f98b9..d380a8c 100644 --- a/cmd/mcp-framework/main.go +++ b/cmd/mcp-framework/main.go @@ -34,6 +34,8 @@ func run(args []string, stdout, stderr io.Writer) error { } switch args[0] { + case "build": + return runBuild(args[1:], stdout, stderr) case "scaffold": return runScaffold(args[1:], stdout, stderr) default: @@ -142,7 +144,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error { func printGlobalHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", + "Usage:\n %s [options]\n\nCommands:\n build Build un binaire MCP de manière standardisée\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", toolName, toolName, ) diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go index 6f32c6f..1476bf7 100644 --- a/cmd/mcp-framework/main_test.go +++ b/cmd/mcp-framework/main_test.go @@ -23,6 +23,9 @@ func TestRunPrintsGlobalHelp(t *testing.T) { if !strings.Contains(output, "scaffold init") { t.Fatalf("global help should mention scaffold init: %q", output) } + if !strings.Contains(output, "build") { + t.Fatalf("global help should mention build: %q", output) + } } func TestRunScaffoldInitCreatesProject(t *testing.T) { diff --git a/manifest/manifest.go b/manifest/manifest.go index b3c9414..b9238f3 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -18,6 +18,7 @@ type File struct { BinaryName string `toml:"binary_name"` DocsURL string `toml:"docs_url"` Update Update `toml:"update"` + Build Build `toml:"build"` Environment Environment `toml:"environment"` SecretStore SecretStore `toml:"secret_store"` Profiles Profiles `toml:"profiles"` @@ -38,6 +39,12 @@ type Update struct { TokenEnvNames []string `toml:"token_env_names"` } +type Build struct { + MainPackage string `toml:"main_package"` + OutputDir string `toml:"output_dir"` + VersionVar string `toml:"version_var"` +} + type Environment struct { Known []string `toml:"known"` } @@ -141,6 +148,7 @@ func (f *File) normalize() { f.BinaryName = strings.TrimSpace(f.BinaryName) f.DocsURL = strings.TrimSpace(f.DocsURL) f.Update.normalize() + f.Build.normalize() f.Environment.normalize() f.SecretStore.normalize() f.Profiles.normalize() @@ -160,6 +168,12 @@ func (u *Update) normalize() { u.TokenEnvNames = normalizeStringList(u.TokenEnvNames) } +func (b *Build) normalize() { + b.MainPackage = strings.TrimSpace(b.MainPackage) + b.OutputDir = strings.TrimSpace(b.OutputDir) + b.VersionVar = strings.TrimSpace(b.VersionVar) +} + func (e *Environment) normalize() { e.Known = normalizeStringList(e.Known) } diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 83ea7d5..6ccf1d6 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -151,6 +151,11 @@ docs_url = " https://docs.example.com/mcp " [update] latest_release_url = "https://example.com/latest" +[build] +main_package = " ./cmd/my-mcp " +output_dir = " build " +version_var = " main.version " + [environment] known = [" MCP_PROFILE ", "", "MCP_TOKEN"] @@ -183,6 +188,15 @@ description = " Client MCP interne " if !slices.Equal(file.Environment.Known, []string{"MCP_PROFILE", "MCP_TOKEN"}) { t.Fatalf("environment known = %v", file.Environment.Known) } + if file.Build.MainPackage != "./cmd/my-mcp" { + t.Fatalf("build main package = %q", file.Build.MainPackage) + } + if file.Build.OutputDir != "build" { + t.Fatalf("build output dir = %q", file.Build.OutputDir) + } + if file.Build.VersionVar != "main.version" { + t.Fatalf("build version var = %q", file.Build.VersionVar) + } if file.SecretStore.BackendPolicy != "auto" { t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy) } diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index bd2c96e..7a543f7 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -661,6 +661,11 @@ token_header = "Authorization" token_prefix = "token" token_env_names = ["{{.ReleaseTokenEnv}}"] +[build] +main_package = "./cmd/{{.BinaryName}}" +output_dir = "build" +version_var = "main.version" + [environment] known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index c166a3b..7425d65 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -85,9 +85,11 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "binary_name = \"my-mcp\"", "[update]", + "[build]", "[secret_store]", "[environment]", "[profiles]", + "version_var = \"main.version\"", "backend_policy = \"auto\"", } { if !strings.Contains(string(manifestContent), snippet) { -- 2.45.2 From f5e52463f260af95680cf86acc427e3918d138da Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 09:09:39 +0200 Subject: [PATCH 19/79] feat: add CI build matrix planning from manifest targets --- README.md | 67 +++++++ cmd/mcp-framework/build.go | 307 +++++++++++++++++++++++++++++--- cmd/mcp-framework/build_test.go | 106 +++++++++++ manifest/manifest.go | 40 ++++- manifest/manifest_test.go | 18 ++ scaffold/scaffold.go | 3 + scaffold/scaffold_test.go | 3 + 7 files changed, 512 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 18502d8..fb61c92 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ les projets consommateurs : go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build ``` +Et une commande de planification de matrice CI : + +```bash +go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build plan --format github +``` + Par défaut la commande : - lit `mcp.toml` (en remontant les répertoires parents) @@ -64,10 +70,59 @@ Options principales : - `--build-dir` - `--goos` - `--goarch` +- `--target` (`os/arch`, ex: `linux/amd64`) - `--version` - `--version-var` - `--ldflag` (répétable) +`build plan` options principales : + +- `--manifest-dir` +- `--binary` +- `--package` +- `--build-dir` +- `--version-var` +- `--format` (`json`, `github`, `gitlab`) + +Exemple GitHub Actions (matrix dynamique) : + +```yaml +jobs: + plan: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - id: plan + run: echo "matrix=$(mcp-framework build plan --format github | tr -d '\n')" >> "$GITHUB_OUTPUT" + + build: + needs: [plan] + strategy: + matrix: ${{ fromJson(needs.plan.outputs.matrix) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: mcp-framework build --target "${{ matrix.target }}" --version "${{ github.ref_name }}" + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: ${{ matrix.artifact_path }} +``` + +Exemple GitLab CI (snippet matrix généré) : + +```bash +mcp-framework build plan --format gitlab > matrix.yml +``` + +Puis inclure le snippet `parallel.matrix` dans le job de build et exécuter : + +```bash +mcp-framework build --target "${GOOS}/${GOARCH}" --version "${CI_COMMIT_TAG}" +``` + ## Packages - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. @@ -161,6 +216,17 @@ token_header = "Authorization" token_prefix = "token" token_env_names = ["GITEA_TOKEN"] +[build] +main_package = "./cmd/my-mcp" +output_dir = "build" +version_var = "main.version" +[[build.targets]] +os = "linux" +arch = "amd64" +[[build.targets]] +os = "darwin" +arch = "arm64" + [environment] known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"] @@ -196,6 +262,7 @@ Champs supportés : - `[build].main_package` : package main à builder (ex: `./cmd/my-mcp`). - `[build].output_dir` : répertoire de sortie des artefacts (défaut `build`). - `[build].version_var` : variable Go ciblée par `-X` pour injecter la version (`main.version`, `gitlab.../internal/app.Version`, etc.). +- `[[build.targets]]` : cibles de compilation (`os`, `arch`) exploitées par `mcp-framework build plan`. - `[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. diff --git a/cmd/mcp-framework/build.go b/cmd/mcp-framework/build.go index de70f8d..1e62344 100644 --- a/cmd/mcp-framework/build.go +++ b/cmd/mcp-framework/build.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "errors" "flag" "fmt" @@ -16,6 +17,43 @@ import ( type stringListFlag []string +type buildTarget struct { + GOOS string + GOARCH string +} + +type buildConfig struct { + ProjectDir string + BinaryName string + MainPackage string + BuildDir string + VersionVar string + ManifestTargets []manifestpkg.BuildTarget +} + +type buildConfigOverrides struct { + BinaryName string + MainPackage string + BuildDir string + VersionVar string +} + +type buildPlanTarget struct { + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + Target string `json:"target"` + ArtifactName string `json:"artifact_name"` + ArtifactPath string `json:"artifact_path"` +} + +type buildPlanOutput struct { + BinaryName string `json:"binary_name"` + MainPackage string `json:"main_package"` + BuildDir string `json:"build_dir"` + VersionVar string `json:"version_var"` + Targets []buildPlanTarget `json:"targets"` +} + func (f *stringListFlag) String() string { return strings.Join(*f, ",") } @@ -26,6 +64,13 @@ func (f *stringListFlag) Set(value string) error { } func runBuild(args []string, stdout, stderr io.Writer) error { + if len(args) > 0 { + switch strings.TrimSpace(args[0]) { + case "plan": + return runBuildPlan(args[1:], stdout, stderr) + } + } + if shouldShowHelp(args) { printBuildHelp(stdout) return nil @@ -34,15 +79,8 @@ func runBuild(args []string, stdout, stderr io.Writer) error { fs := flag.NewFlagSet("build", flag.ContinueOnError) fs.SetOutput(io.Discard) - defaultGOOS := strings.TrimSpace(os.Getenv("GOOS")) - if defaultGOOS == "" { - defaultGOOS = runtime.GOOS - } - - defaultGOARCH := strings.TrimSpace(os.Getenv("GOARCH")) - if defaultGOARCH == "" { - defaultGOARCH = runtime.GOARCH - } + defaultGOOS := resolveDefaultGOOS() + defaultGOARCH := resolveDefaultGOARCH() var manifestDir string var binaryName string @@ -50,6 +88,7 @@ func runBuild(args []string, stdout, stderr io.Writer) error { var buildDir string var goos string var goarch string + var target string var version string var versionVar string var gocache string @@ -61,6 +100,7 @@ func runBuild(args []string, stdout, stderr io.Writer) error { fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") fs.StringVar(&goos, "goos", defaultGOOS, "GOOS cible") fs.StringVar(&goarch, "goarch", defaultGOARCH, "GOARCH cible") + fs.StringVar(&target, "target", "", "Cible au format os/arch (override --goos/--goarch)") fs.StringVar(&version, "version", "", "Version injectée (par défaut VERSION env, puis git describe, puis dev)") fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") fs.StringVar(&gocache, "gocache", strings.TrimSpace(os.Getenv("GOCACHE")), "Valeur GOCACHE pour go build") @@ -75,30 +115,31 @@ func runBuild(args []string, stdout, stderr io.Writer) error { return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) } - file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) + cfg, err := resolveBuildConfig(manifestDir, buildConfigOverrides{ + BinaryName: binaryName, + MainPackage: mainPackage, + BuildDir: buildDir, + VersionVar: versionVar, + }) if err != nil { return err } - projectDir := filepath.Dir(manifestPath) - - binaryName = firstNonEmpty(binaryName, file.BinaryName) - if binaryName == "" { - return errors.New("binary name is required (set binary_name in mcp.toml or --binary)") + if strings.TrimSpace(target) != "" { + parsedTarget, err := parseBuildTarget(target) + if err != nil { + return err + } + goos = parsedTarget.GOOS + goarch = parsedTarget.GOARCH } - mainPackage = firstNonEmpty(mainPackage, file.Build.MainPackage) - if mainPackage == "" { - mainPackage = fmt.Sprintf("./cmd/%s", binaryName) - } + goos = firstNonEmpty(goos, resolveDefaultGOOS()) + goarch = firstNonEmpty(goarch, resolveDefaultGOARCH()) + version = resolveBuildVersion(version, cfg.ProjectDir) + versionVar = cfg.VersionVar - buildDir = firstNonEmpty(buildDir, file.Build.OutputDir, "build") - goos = firstNonEmpty(goos, runtime.GOOS) - goarch = firstNonEmpty(goarch, runtime.GOARCH) - version = resolveBuildVersion(version, projectDir) - versionVar = firstNonEmpty(versionVar, file.Build.VersionVar, "main.version") - - outputPath, err := buildOutputPath(projectDir, buildDir, binaryName, goos, goarch) + outputPath, err := buildOutputPath(cfg.ProjectDir, cfg.BuildDir, cfg.BinaryName, goos, goarch) if err != nil { return err } @@ -120,10 +161,10 @@ func runBuild(args []string, stdout, stderr io.Writer) error { if len(ldflags) > 0 { cmdArgs = append(cmdArgs, "-ldflags", strings.Join(ldflags, " ")) } - cmdArgs = append(cmdArgs, "-o", outputPath, mainPackage) + cmdArgs = append(cmdArgs, "-o", outputPath, cfg.MainPackage) cmd := exec.Command("go", cmdArgs...) - cmd.Dir = projectDir + cmd.Dir = cfg.ProjectDir cmd.Stdout = stdout cmd.Stderr = stderr cmd.Env = withEnvOverrides(os.Environ(), map[string]string{ @@ -143,10 +184,210 @@ func runBuild(args []string, stdout, stderr io.Writer) error { return nil } +func runBuildPlan(args []string, stdout, stderr io.Writer) error { + if shouldShowHelp(args) { + printBuildPlanHelp(stdout) + return nil + } + + fs := flag.NewFlagSet("build plan", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var manifestDir string + var binaryName string + var mainPackage string + var buildDir string + var versionVar string + var format string + + fs.StringVar(&manifestDir, "manifest-dir", ".", "Répertoire de départ pour trouver mcp.toml") + fs.StringVar(&binaryName, "binary", "", "Nom du binaire (override binary_name)") + fs.StringVar(&mainPackage, "package", "", "Package main à builder (override [build].main_package)") + fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") + fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") + fs.StringVar(&format, "format", "json", "Format de sortie: json|github|gitlab") + + if err := fs.Parse(args); err != nil { + _ = stderr + return fmt.Errorf("parse build plan flags: %w", err) + } + + if fs.NArg() > 0 { + return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) + } + + cfg, err := resolveBuildConfig(manifestDir, buildConfigOverrides{ + BinaryName: binaryName, + MainPackage: mainPackage, + BuildDir: buildDir, + VersionVar: versionVar, + }) + if err != nil { + return err + } + + targets, err := resolveBuildTargets(cfg.ManifestTargets, resolveDefaultGOOS(), resolveDefaultGOARCH()) + if err != nil { + return err + } + + planTargets := make([]buildPlanTarget, 0, len(targets)) + for _, target := range targets { + outputPath, err := buildOutputPath(cfg.ProjectDir, cfg.BuildDir, cfg.BinaryName, target.GOOS, target.GOARCH) + if err != nil { + return err + } + + planTargets = append(planTargets, buildPlanTarget{ + GOOS: target.GOOS, + GOARCH: target.GOARCH, + Target: formatBuildTarget(target.GOOS, target.GOARCH), + ArtifactName: filepath.Base(outputPath), + ArtifactPath: formatArtifactPath(cfg.ProjectDir, outputPath), + }) + } + + switch strings.ToLower(strings.TrimSpace(format)) { + case "json": + return renderJSON(stdout, buildPlanOutput{ + BinaryName: cfg.BinaryName, + MainPackage: cfg.MainPackage, + BuildDir: cfg.BuildDir, + VersionVar: cfg.VersionVar, + Targets: planTargets, + }) + case "github": + include := make([]map[string]string, 0, len(planTargets)) + for _, target := range planTargets { + include = append(include, map[string]string{ + "goos": target.GOOS, + "goarch": target.GOARCH, + "target": target.Target, + "artifact_name": target.ArtifactName, + "artifact_path": target.ArtifactPath, + }) + } + return renderJSON(stdout, map[string]any{"include": include}) + case "gitlab": + return renderGitLabMatrix(stdout, planTargets) + default: + return fmt.Errorf("unsupported build plan format %q (expected json, github or gitlab)", format) + } +} + +func resolveBuildConfig(manifestDir string, overrides buildConfigOverrides) (buildConfig, error) { + file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) + if err != nil { + return buildConfig{}, err + } + + projectDir := filepath.Dir(manifestPath) + binaryName := firstNonEmpty(overrides.BinaryName, file.BinaryName) + if binaryName == "" { + return buildConfig{}, errors.New("binary name is required (set binary_name in mcp.toml or --binary)") + } + + mainPackage := firstNonEmpty(overrides.MainPackage, file.Build.MainPackage) + if mainPackage == "" { + mainPackage = fmt.Sprintf("./cmd/%s", binaryName) + } + + return buildConfig{ + ProjectDir: projectDir, + BinaryName: binaryName, + MainPackage: mainPackage, + BuildDir: firstNonEmpty(overrides.BuildDir, file.Build.OutputDir, "build"), + VersionVar: firstNonEmpty(overrides.VersionVar, file.Build.VersionVar, "main.version"), + ManifestTargets: append([]manifestpkg.BuildTarget(nil), file.Build.Targets...), + }, nil +} + +func resolveBuildTargets(manifestTargets []manifestpkg.BuildTarget, defaultGOOS, defaultGOARCH string) ([]buildTarget, error) { + if len(manifestTargets) == 0 { + defaultTarget, err := parseBuildTarget(formatBuildTarget(defaultGOOS, defaultGOARCH)) + if err != nil { + return nil, err + } + return []buildTarget{defaultTarget}, nil + } + + targets := make([]buildTarget, 0, len(manifestTargets)) + for _, manifestTarget := range manifestTargets { + target, err := parseBuildTarget(formatBuildTarget(manifestTarget.OS, manifestTarget.Arch)) + if err != nil { + return nil, err + } + targets = append(targets, target) + } + + return targets, nil +} + +func parseBuildTarget(value string) (buildTarget, error) { + raw := strings.TrimSpace(value) + parts := strings.Split(raw, "/") + if len(parts) != 2 { + return buildTarget{}, fmt.Errorf("invalid target %q (expected os/arch)", value) + } + + goos := strings.ToLower(strings.TrimSpace(parts[0])) + goarch := strings.ToLower(strings.TrimSpace(parts[1])) + if goos == "" || goarch == "" { + return buildTarget{}, fmt.Errorf("invalid target %q (expected non-empty os/arch)", value) + } + + return buildTarget{GOOS: goos, GOARCH: goarch}, nil +} + +func formatBuildTarget(goos, goarch string) string { + return strings.ToLower(strings.TrimSpace(goos)) + "/" + strings.ToLower(strings.TrimSpace(goarch)) +} + +func formatArtifactPath(projectDir, outputPath string) string { + rel, err := filepath.Rel(projectDir, outputPath) + if err == nil && rel != "" && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) { + return filepath.ToSlash(rel) + } + return filepath.ToSlash(outputPath) +} + +func renderJSON(w io.Writer, payload any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(payload) +} + +func renderGitLabMatrix(w io.Writer, targets []buildPlanTarget) error { + if _, err := fmt.Fprintln(w, "parallel:"); err != nil { + return err + } + if _, err := fmt.Fprintln(w, " matrix:"); err != nil { + return err + } + for _, target := range targets { + if _, err := fmt.Fprintf(w, " - GOOS: %q\n", target.GOOS); err != nil { + return err + } + if _, err := fmt.Fprintf(w, " GOARCH: %q\n", target.GOARCH); err != nil { + return err + } + } + return nil +} + func printBuildHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s build [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n", + "Usage:\n %s build [flags]\n %s build plan [flags]\n\nBuild flags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --target Cible os/arch (ex: linux/amd64), prioritaire sur --goos/--goarch\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nBuild plan flags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --version-var Override de [build].version_var\n --format json|github|gitlab\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n [[build.targets]]\n os = \"linux\"\n arch = \"amd64\"\n", + toolName, + toolName, + ) +} + +func printBuildPlanHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s build plan [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --version-var Override de [build].version_var\n --format json|github|gitlab (défaut: json)\n", toolName, ) } @@ -232,3 +473,11 @@ func firstNonEmpty(values ...string) string { } return "" } + +func resolveDefaultGOOS() string { + return firstNonEmpty(os.Getenv("GOOS"), runtime.GOOS) +} + +func resolveDefaultGOARCH() string { + return firstNonEmpty(os.Getenv("GOARCH"), runtime.GOARCH) +} diff --git a/cmd/mcp-framework/build_test.go b/cmd/mcp-framework/build_test.go index 5fa1fd4..85b9718 100644 --- a/cmd/mcp-framework/build_test.go +++ b/cmd/mcp-framework/build_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/json" "os" "os/exec" "path/filepath" @@ -110,6 +111,108 @@ func main() { } } +func TestRunBuildSupportsTargetFlag(t *testing.T) { + projectDir := t.TempDir() + writeBuildFixture(t, projectDir, "target-flag", ` +module example.com/target-flag + +go 1.25.0 +`, ` +binary_name = "target-flag" +`, ` +package main + +import "fmt" + +var version = "dev" + +func main() { + fmt.Print(version) +} +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run([]string{ + "build", + "--manifest-dir", projectDir, + "--target", runtime.GOOS + "/" + runtime.GOARCH, + "--version", "2.0.0", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) + } + + artifactPath := artifactPath(projectDir, "build", "target-flag", runtime.GOOS, runtime.GOARCH) + output, err := exec.Command(artifactPath).Output() + if err != nil { + t.Fatalf("run artifact: %v", err) + } + if strings.TrimSpace(string(output)) != "2.0.0" { + t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "2.0.0") + } +} + +func TestRunBuildPlanGithubFormat(t *testing.T) { + projectDir := t.TempDir() + writeBuildFixture(t, projectDir, "matrix-mcp", ` +module example.com/matrix-mcp + +go 1.25.0 +`, ` +binary_name = "matrix-mcp" + +[build] +output_dir = "dist" +[[build.targets]] +os = "linux" +arch = "amd64" +[[build.targets]] +os = "darwin" +arch = "arm64" +`, ` +package main + +func main() {} +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run([]string{ + "build", "plan", + "--manifest-dir", projectDir, + "--format", "github", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) + } + + var payload struct { + Include []struct { + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + Target string `json:"target"` + ArtifactPath string `json:"artifact_path"` + } `json:"include"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode github matrix: %v", err) + } + if len(payload.Include) != 2 { + t.Fatalf("include length = %d, want 2", len(payload.Include)) + } + + if payload.Include[0].Target != "linux/amd64" { + t.Fatalf("first target = %q", payload.Include[0].Target) + } + if payload.Include[0].ArtifactPath != "dist/matrix-mcp-linux-amd64" { + t.Fatalf("first artifact path = %q", payload.Include[0].ArtifactPath) + } + if payload.Include[1].Target != "darwin/arm64" { + t.Fatalf("second target = %q", payload.Include[1].Target) + } +} + func TestBuildHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -125,6 +228,9 @@ func TestBuildHelp(t *testing.T) { if !strings.Contains(output, "version_var") { t.Fatalf("build help should mention manifest build config: %q", output) } + if !strings.Contains(output, "build plan") { + t.Fatalf("build help should mention build plan: %q", output) + } } func writeBuildFixture(t *testing.T, projectDir, binaryName, goModContent, manifestContent, mainContent string) { diff --git a/manifest/manifest.go b/manifest/manifest.go index b9238f3..5c686c7 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -40,9 +40,15 @@ type Update struct { } type Build struct { - MainPackage string `toml:"main_package"` - OutputDir string `toml:"output_dir"` - VersionVar string `toml:"version_var"` + MainPackage string `toml:"main_package"` + OutputDir string `toml:"output_dir"` + VersionVar string `toml:"version_var"` + Targets []BuildTarget `toml:"targets"` +} + +type BuildTarget struct { + OS string `toml:"os"` + Arch string `toml:"arch"` } type Environment struct { @@ -172,6 +178,7 @@ func (b *Build) normalize() { b.MainPackage = strings.TrimSpace(b.MainPackage) b.OutputDir = strings.TrimSpace(b.OutputDir) b.VersionVar = strings.TrimSpace(b.VersionVar) + b.Targets = normalizeBuildTargets(b.Targets) } func (e *Environment) normalize() { @@ -243,3 +250,30 @@ func normalizeStringList(values []string) []string { } return normalized } + +func normalizeBuildTargets(values []BuildTarget) []BuildTarget { + if len(values) == 0 { + return values + } + + normalized := values[:0] + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + target := BuildTarget{ + OS: strings.ToLower(strings.TrimSpace(value.OS)), + Arch: strings.ToLower(strings.TrimSpace(value.Arch)), + } + if target.OS == "" || target.Arch == "" { + continue + } + + key := target.OS + "/" + target.Arch + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, target) + } + + return normalized +} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 6ccf1d6..42e632f 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -155,6 +155,18 @@ latest_release_url = "https://example.com/latest" main_package = " ./cmd/my-mcp " output_dir = " build " version_var = " main.version " +[[build.targets]] +os = " Linux " +arch = " AMD64 " +[[build.targets]] +os = "darwin" +arch = "arm64" +[[build.targets]] +os = "linux" +arch = "amd64" +[[build.targets]] +os = "" +arch = "amd64" [environment] known = [" MCP_PROFILE ", "", "MCP_TOKEN"] @@ -197,6 +209,12 @@ description = " Client MCP interne " if file.Build.VersionVar != "main.version" { t.Fatalf("build version var = %q", file.Build.VersionVar) } + if !slices.Equal(file.Build.Targets, []BuildTarget{ + {OS: "linux", Arch: "amd64"}, + {OS: "darwin", Arch: "arm64"}, + }) { + t.Fatalf("build targets = %#v", file.Build.Targets) + } if file.SecretStore.BackendPolicy != "auto" { t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy) } diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 7a543f7..3ee85da 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -665,6 +665,9 @@ token_env_names = ["{{.ReleaseTokenEnv}}"] main_package = "./cmd/{{.BinaryName}}" output_dir = "build" version_var = "main.version" +[[build.targets]] +os = "linux" +arch = "amd64" [environment] known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 7425d65..e7ad407 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -90,6 +90,9 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "[environment]", "[profiles]", "version_var = \"main.version\"", + "[[build.targets]]", + "os = \"linux\"", + "arch = \"amd64\"", "backend_policy = \"auto\"", } { if !strings.Contains(string(manifestContent), snippet) { -- 2.45.2 From 5e115893cc2d9d71c28b0d26bf6da1eb893e2827 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 09:37:01 +0200 Subject: [PATCH 20/79] Revert "feat: add CI build matrix planning from manifest targets" This reverts commit f5e52463f260af95680cf86acc427e3918d138da. --- README.md | 67 ------- cmd/mcp-framework/build.go | 307 +++----------------------------- cmd/mcp-framework/build_test.go | 106 ----------- manifest/manifest.go | 40 +---- manifest/manifest_test.go | 18 -- scaffold/scaffold.go | 3 - scaffold/scaffold_test.go | 3 - 7 files changed, 32 insertions(+), 512 deletions(-) diff --git a/README.md b/README.md index fb61c92..18502d8 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,6 @@ les projets consommateurs : go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build ``` -Et une commande de planification de matrice CI : - -```bash -go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build plan --format github -``` - Par défaut la commande : - lit `mcp.toml` (en remontant les répertoires parents) @@ -70,59 +64,10 @@ Options principales : - `--build-dir` - `--goos` - `--goarch` -- `--target` (`os/arch`, ex: `linux/amd64`) - `--version` - `--version-var` - `--ldflag` (répétable) -`build plan` options principales : - -- `--manifest-dir` -- `--binary` -- `--package` -- `--build-dir` -- `--version-var` -- `--format` (`json`, `github`, `gitlab`) - -Exemple GitHub Actions (matrix dynamique) : - -```yaml -jobs: - plan: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.plan.outputs.matrix }} - steps: - - uses: actions/checkout@v4 - - id: plan - run: echo "matrix=$(mcp-framework build plan --format github | tr -d '\n')" >> "$GITHUB_OUTPUT" - - build: - needs: [plan] - strategy: - matrix: ${{ fromJson(needs.plan.outputs.matrix) }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: mcp-framework build --target "${{ matrix.target }}" --version "${{ github.ref_name }}" - - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact_name }} - path: ${{ matrix.artifact_path }} -``` - -Exemple GitLab CI (snippet matrix généré) : - -```bash -mcp-framework build plan --format gitlab > matrix.yml -``` - -Puis inclure le snippet `parallel.matrix` dans le job de build et exécuter : - -```bash -mcp-framework build --target "${GOOS}/${GOARCH}" --version "${CI_COMMIT_TAG}" -``` - ## Packages - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. @@ -216,17 +161,6 @@ token_header = "Authorization" token_prefix = "token" token_env_names = ["GITEA_TOKEN"] -[build] -main_package = "./cmd/my-mcp" -output_dir = "build" -version_var = "main.version" -[[build.targets]] -os = "linux" -arch = "amd64" -[[build.targets]] -os = "darwin" -arch = "arm64" - [environment] known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"] @@ -262,7 +196,6 @@ Champs supportés : - `[build].main_package` : package main à builder (ex: `./cmd/my-mcp`). - `[build].output_dir` : répertoire de sortie des artefacts (défaut `build`). - `[build].version_var` : variable Go ciblée par `-X` pour injecter la version (`main.version`, `gitlab.../internal/app.Version`, etc.). -- `[[build.targets]]` : cibles de compilation (`os`, `arch`) exploitées par `mcp-framework build plan`. - `[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. diff --git a/cmd/mcp-framework/build.go b/cmd/mcp-framework/build.go index 1e62344..de70f8d 100644 --- a/cmd/mcp-framework/build.go +++ b/cmd/mcp-framework/build.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "errors" "flag" "fmt" @@ -17,43 +16,6 @@ import ( type stringListFlag []string -type buildTarget struct { - GOOS string - GOARCH string -} - -type buildConfig struct { - ProjectDir string - BinaryName string - MainPackage string - BuildDir string - VersionVar string - ManifestTargets []manifestpkg.BuildTarget -} - -type buildConfigOverrides struct { - BinaryName string - MainPackage string - BuildDir string - VersionVar string -} - -type buildPlanTarget struct { - GOOS string `json:"goos"` - GOARCH string `json:"goarch"` - Target string `json:"target"` - ArtifactName string `json:"artifact_name"` - ArtifactPath string `json:"artifact_path"` -} - -type buildPlanOutput struct { - BinaryName string `json:"binary_name"` - MainPackage string `json:"main_package"` - BuildDir string `json:"build_dir"` - VersionVar string `json:"version_var"` - Targets []buildPlanTarget `json:"targets"` -} - func (f *stringListFlag) String() string { return strings.Join(*f, ",") } @@ -64,13 +26,6 @@ func (f *stringListFlag) Set(value string) error { } func runBuild(args []string, stdout, stderr io.Writer) error { - if len(args) > 0 { - switch strings.TrimSpace(args[0]) { - case "plan": - return runBuildPlan(args[1:], stdout, stderr) - } - } - if shouldShowHelp(args) { printBuildHelp(stdout) return nil @@ -79,8 +34,15 @@ func runBuild(args []string, stdout, stderr io.Writer) error { fs := flag.NewFlagSet("build", flag.ContinueOnError) fs.SetOutput(io.Discard) - defaultGOOS := resolveDefaultGOOS() - defaultGOARCH := resolveDefaultGOARCH() + defaultGOOS := strings.TrimSpace(os.Getenv("GOOS")) + if defaultGOOS == "" { + defaultGOOS = runtime.GOOS + } + + defaultGOARCH := strings.TrimSpace(os.Getenv("GOARCH")) + if defaultGOARCH == "" { + defaultGOARCH = runtime.GOARCH + } var manifestDir string var binaryName string @@ -88,7 +50,6 @@ func runBuild(args []string, stdout, stderr io.Writer) error { var buildDir string var goos string var goarch string - var target string var version string var versionVar string var gocache string @@ -100,7 +61,6 @@ func runBuild(args []string, stdout, stderr io.Writer) error { fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") fs.StringVar(&goos, "goos", defaultGOOS, "GOOS cible") fs.StringVar(&goarch, "goarch", defaultGOARCH, "GOARCH cible") - fs.StringVar(&target, "target", "", "Cible au format os/arch (override --goos/--goarch)") fs.StringVar(&version, "version", "", "Version injectée (par défaut VERSION env, puis git describe, puis dev)") fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") fs.StringVar(&gocache, "gocache", strings.TrimSpace(os.Getenv("GOCACHE")), "Valeur GOCACHE pour go build") @@ -115,31 +75,30 @@ func runBuild(args []string, stdout, stderr io.Writer) error { return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) } - cfg, err := resolveBuildConfig(manifestDir, buildConfigOverrides{ - BinaryName: binaryName, - MainPackage: mainPackage, - BuildDir: buildDir, - VersionVar: versionVar, - }) + file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) if err != nil { return err } - if strings.TrimSpace(target) != "" { - parsedTarget, err := parseBuildTarget(target) - if err != nil { - return err - } - goos = parsedTarget.GOOS - goarch = parsedTarget.GOARCH + projectDir := filepath.Dir(manifestPath) + + binaryName = firstNonEmpty(binaryName, file.BinaryName) + if binaryName == "" { + return errors.New("binary name is required (set binary_name in mcp.toml or --binary)") } - goos = firstNonEmpty(goos, resolveDefaultGOOS()) - goarch = firstNonEmpty(goarch, resolveDefaultGOARCH()) - version = resolveBuildVersion(version, cfg.ProjectDir) - versionVar = cfg.VersionVar + mainPackage = firstNonEmpty(mainPackage, file.Build.MainPackage) + if mainPackage == "" { + mainPackage = fmt.Sprintf("./cmd/%s", binaryName) + } - outputPath, err := buildOutputPath(cfg.ProjectDir, cfg.BuildDir, cfg.BinaryName, goos, goarch) + buildDir = firstNonEmpty(buildDir, file.Build.OutputDir, "build") + goos = firstNonEmpty(goos, runtime.GOOS) + goarch = firstNonEmpty(goarch, runtime.GOARCH) + version = resolveBuildVersion(version, projectDir) + versionVar = firstNonEmpty(versionVar, file.Build.VersionVar, "main.version") + + outputPath, err := buildOutputPath(projectDir, buildDir, binaryName, goos, goarch) if err != nil { return err } @@ -161,10 +120,10 @@ func runBuild(args []string, stdout, stderr io.Writer) error { if len(ldflags) > 0 { cmdArgs = append(cmdArgs, "-ldflags", strings.Join(ldflags, " ")) } - cmdArgs = append(cmdArgs, "-o", outputPath, cfg.MainPackage) + cmdArgs = append(cmdArgs, "-o", outputPath, mainPackage) cmd := exec.Command("go", cmdArgs...) - cmd.Dir = cfg.ProjectDir + cmd.Dir = projectDir cmd.Stdout = stdout cmd.Stderr = stderr cmd.Env = withEnvOverrides(os.Environ(), map[string]string{ @@ -184,210 +143,10 @@ func runBuild(args []string, stdout, stderr io.Writer) error { return nil } -func runBuildPlan(args []string, stdout, stderr io.Writer) error { - if shouldShowHelp(args) { - printBuildPlanHelp(stdout) - return nil - } - - fs := flag.NewFlagSet("build plan", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - var manifestDir string - var binaryName string - var mainPackage string - var buildDir string - var versionVar string - var format string - - fs.StringVar(&manifestDir, "manifest-dir", ".", "Répertoire de départ pour trouver mcp.toml") - fs.StringVar(&binaryName, "binary", "", "Nom du binaire (override binary_name)") - fs.StringVar(&mainPackage, "package", "", "Package main à builder (override [build].main_package)") - fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") - fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") - fs.StringVar(&format, "format", "json", "Format de sortie: json|github|gitlab") - - if err := fs.Parse(args); err != nil { - _ = stderr - return fmt.Errorf("parse build plan flags: %w", err) - } - - if fs.NArg() > 0 { - return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) - } - - cfg, err := resolveBuildConfig(manifestDir, buildConfigOverrides{ - BinaryName: binaryName, - MainPackage: mainPackage, - BuildDir: buildDir, - VersionVar: versionVar, - }) - if err != nil { - return err - } - - targets, err := resolveBuildTargets(cfg.ManifestTargets, resolveDefaultGOOS(), resolveDefaultGOARCH()) - if err != nil { - return err - } - - planTargets := make([]buildPlanTarget, 0, len(targets)) - for _, target := range targets { - outputPath, err := buildOutputPath(cfg.ProjectDir, cfg.BuildDir, cfg.BinaryName, target.GOOS, target.GOARCH) - if err != nil { - return err - } - - planTargets = append(planTargets, buildPlanTarget{ - GOOS: target.GOOS, - GOARCH: target.GOARCH, - Target: formatBuildTarget(target.GOOS, target.GOARCH), - ArtifactName: filepath.Base(outputPath), - ArtifactPath: formatArtifactPath(cfg.ProjectDir, outputPath), - }) - } - - switch strings.ToLower(strings.TrimSpace(format)) { - case "json": - return renderJSON(stdout, buildPlanOutput{ - BinaryName: cfg.BinaryName, - MainPackage: cfg.MainPackage, - BuildDir: cfg.BuildDir, - VersionVar: cfg.VersionVar, - Targets: planTargets, - }) - case "github": - include := make([]map[string]string, 0, len(planTargets)) - for _, target := range planTargets { - include = append(include, map[string]string{ - "goos": target.GOOS, - "goarch": target.GOARCH, - "target": target.Target, - "artifact_name": target.ArtifactName, - "artifact_path": target.ArtifactPath, - }) - } - return renderJSON(stdout, map[string]any{"include": include}) - case "gitlab": - return renderGitLabMatrix(stdout, planTargets) - default: - return fmt.Errorf("unsupported build plan format %q (expected json, github or gitlab)", format) - } -} - -func resolveBuildConfig(manifestDir string, overrides buildConfigOverrides) (buildConfig, error) { - file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) - if err != nil { - return buildConfig{}, err - } - - projectDir := filepath.Dir(manifestPath) - binaryName := firstNonEmpty(overrides.BinaryName, file.BinaryName) - if binaryName == "" { - return buildConfig{}, errors.New("binary name is required (set binary_name in mcp.toml or --binary)") - } - - mainPackage := firstNonEmpty(overrides.MainPackage, file.Build.MainPackage) - if mainPackage == "" { - mainPackage = fmt.Sprintf("./cmd/%s", binaryName) - } - - return buildConfig{ - ProjectDir: projectDir, - BinaryName: binaryName, - MainPackage: mainPackage, - BuildDir: firstNonEmpty(overrides.BuildDir, file.Build.OutputDir, "build"), - VersionVar: firstNonEmpty(overrides.VersionVar, file.Build.VersionVar, "main.version"), - ManifestTargets: append([]manifestpkg.BuildTarget(nil), file.Build.Targets...), - }, nil -} - -func resolveBuildTargets(manifestTargets []manifestpkg.BuildTarget, defaultGOOS, defaultGOARCH string) ([]buildTarget, error) { - if len(manifestTargets) == 0 { - defaultTarget, err := parseBuildTarget(formatBuildTarget(defaultGOOS, defaultGOARCH)) - if err != nil { - return nil, err - } - return []buildTarget{defaultTarget}, nil - } - - targets := make([]buildTarget, 0, len(manifestTargets)) - for _, manifestTarget := range manifestTargets { - target, err := parseBuildTarget(formatBuildTarget(manifestTarget.OS, manifestTarget.Arch)) - if err != nil { - return nil, err - } - targets = append(targets, target) - } - - return targets, nil -} - -func parseBuildTarget(value string) (buildTarget, error) { - raw := strings.TrimSpace(value) - parts := strings.Split(raw, "/") - if len(parts) != 2 { - return buildTarget{}, fmt.Errorf("invalid target %q (expected os/arch)", value) - } - - goos := strings.ToLower(strings.TrimSpace(parts[0])) - goarch := strings.ToLower(strings.TrimSpace(parts[1])) - if goos == "" || goarch == "" { - return buildTarget{}, fmt.Errorf("invalid target %q (expected non-empty os/arch)", value) - } - - return buildTarget{GOOS: goos, GOARCH: goarch}, nil -} - -func formatBuildTarget(goos, goarch string) string { - return strings.ToLower(strings.TrimSpace(goos)) + "/" + strings.ToLower(strings.TrimSpace(goarch)) -} - -func formatArtifactPath(projectDir, outputPath string) string { - rel, err := filepath.Rel(projectDir, outputPath) - if err == nil && rel != "" && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) { - return filepath.ToSlash(rel) - } - return filepath.ToSlash(outputPath) -} - -func renderJSON(w io.Writer, payload any) error { - encoder := json.NewEncoder(w) - encoder.SetIndent("", " ") - return encoder.Encode(payload) -} - -func renderGitLabMatrix(w io.Writer, targets []buildPlanTarget) error { - if _, err := fmt.Fprintln(w, "parallel:"); err != nil { - return err - } - if _, err := fmt.Fprintln(w, " matrix:"); err != nil { - return err - } - for _, target := range targets { - if _, err := fmt.Fprintf(w, " - GOOS: %q\n", target.GOOS); err != nil { - return err - } - if _, err := fmt.Fprintf(w, " GOARCH: %q\n", target.GOARCH); err != nil { - return err - } - } - return nil -} - func printBuildHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s build [flags]\n %s build plan [flags]\n\nBuild flags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --target Cible os/arch (ex: linux/amd64), prioritaire sur --goos/--goarch\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nBuild plan flags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --version-var Override de [build].version_var\n --format json|github|gitlab\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n [[build.targets]]\n os = \"linux\"\n arch = \"amd64\"\n", - toolName, - toolName, - ) -} - -func printBuildPlanHelp(w io.Writer) { - fmt.Fprintf( - w, - "Usage:\n %s build plan [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --version-var Override de [build].version_var\n --format json|github|gitlab (défaut: json)\n", + "Usage:\n %s build [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n", toolName, ) } @@ -473,11 +232,3 @@ func firstNonEmpty(values ...string) string { } return "" } - -func resolveDefaultGOOS() string { - return firstNonEmpty(os.Getenv("GOOS"), runtime.GOOS) -} - -func resolveDefaultGOARCH() string { - return firstNonEmpty(os.Getenv("GOARCH"), runtime.GOARCH) -} diff --git a/cmd/mcp-framework/build_test.go b/cmd/mcp-framework/build_test.go index 85b9718..5fa1fd4 100644 --- a/cmd/mcp-framework/build_test.go +++ b/cmd/mcp-framework/build_test.go @@ -2,7 +2,6 @@ package main import ( "bytes" - "encoding/json" "os" "os/exec" "path/filepath" @@ -111,108 +110,6 @@ func main() { } } -func TestRunBuildSupportsTargetFlag(t *testing.T) { - projectDir := t.TempDir() - writeBuildFixture(t, projectDir, "target-flag", ` -module example.com/target-flag - -go 1.25.0 -`, ` -binary_name = "target-flag" -`, ` -package main - -import "fmt" - -var version = "dev" - -func main() { - fmt.Print(version) -} -`) - - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run([]string{ - "build", - "--manifest-dir", projectDir, - "--target", runtime.GOOS + "/" + runtime.GOARCH, - "--version", "2.0.0", - }, &stdout, &stderr) - if err != nil { - t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) - } - - artifactPath := artifactPath(projectDir, "build", "target-flag", runtime.GOOS, runtime.GOARCH) - output, err := exec.Command(artifactPath).Output() - if err != nil { - t.Fatalf("run artifact: %v", err) - } - if strings.TrimSpace(string(output)) != "2.0.0" { - t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "2.0.0") - } -} - -func TestRunBuildPlanGithubFormat(t *testing.T) { - projectDir := t.TempDir() - writeBuildFixture(t, projectDir, "matrix-mcp", ` -module example.com/matrix-mcp - -go 1.25.0 -`, ` -binary_name = "matrix-mcp" - -[build] -output_dir = "dist" -[[build.targets]] -os = "linux" -arch = "amd64" -[[build.targets]] -os = "darwin" -arch = "arm64" -`, ` -package main - -func main() {} -`) - - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run([]string{ - "build", "plan", - "--manifest-dir", projectDir, - "--format", "github", - }, &stdout, &stderr) - if err != nil { - t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) - } - - var payload struct { - Include []struct { - GOOS string `json:"goos"` - GOARCH string `json:"goarch"` - Target string `json:"target"` - ArtifactPath string `json:"artifact_path"` - } `json:"include"` - } - if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { - t.Fatalf("decode github matrix: %v", err) - } - if len(payload.Include) != 2 { - t.Fatalf("include length = %d, want 2", len(payload.Include)) - } - - if payload.Include[0].Target != "linux/amd64" { - t.Fatalf("first target = %q", payload.Include[0].Target) - } - if payload.Include[0].ArtifactPath != "dist/matrix-mcp-linux-amd64" { - t.Fatalf("first artifact path = %q", payload.Include[0].ArtifactPath) - } - if payload.Include[1].Target != "darwin/arm64" { - t.Fatalf("second target = %q", payload.Include[1].Target) - } -} - func TestBuildHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -228,9 +125,6 @@ func TestBuildHelp(t *testing.T) { if !strings.Contains(output, "version_var") { t.Fatalf("build help should mention manifest build config: %q", output) } - if !strings.Contains(output, "build plan") { - t.Fatalf("build help should mention build plan: %q", output) - } } func writeBuildFixture(t *testing.T, projectDir, binaryName, goModContent, manifestContent, mainContent string) { diff --git a/manifest/manifest.go b/manifest/manifest.go index 5c686c7..b9238f3 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -40,15 +40,9 @@ type Update struct { } type Build struct { - MainPackage string `toml:"main_package"` - OutputDir string `toml:"output_dir"` - VersionVar string `toml:"version_var"` - Targets []BuildTarget `toml:"targets"` -} - -type BuildTarget struct { - OS string `toml:"os"` - Arch string `toml:"arch"` + MainPackage string `toml:"main_package"` + OutputDir string `toml:"output_dir"` + VersionVar string `toml:"version_var"` } type Environment struct { @@ -178,7 +172,6 @@ func (b *Build) normalize() { b.MainPackage = strings.TrimSpace(b.MainPackage) b.OutputDir = strings.TrimSpace(b.OutputDir) b.VersionVar = strings.TrimSpace(b.VersionVar) - b.Targets = normalizeBuildTargets(b.Targets) } func (e *Environment) normalize() { @@ -250,30 +243,3 @@ func normalizeStringList(values []string) []string { } return normalized } - -func normalizeBuildTargets(values []BuildTarget) []BuildTarget { - if len(values) == 0 { - return values - } - - normalized := values[:0] - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - target := BuildTarget{ - OS: strings.ToLower(strings.TrimSpace(value.OS)), - Arch: strings.ToLower(strings.TrimSpace(value.Arch)), - } - if target.OS == "" || target.Arch == "" { - continue - } - - key := target.OS + "/" + target.Arch - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - normalized = append(normalized, target) - } - - return normalized -} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 42e632f..6ccf1d6 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -155,18 +155,6 @@ latest_release_url = "https://example.com/latest" main_package = " ./cmd/my-mcp " output_dir = " build " version_var = " main.version " -[[build.targets]] -os = " Linux " -arch = " AMD64 " -[[build.targets]] -os = "darwin" -arch = "arm64" -[[build.targets]] -os = "linux" -arch = "amd64" -[[build.targets]] -os = "" -arch = "amd64" [environment] known = [" MCP_PROFILE ", "", "MCP_TOKEN"] @@ -209,12 +197,6 @@ description = " Client MCP interne " if file.Build.VersionVar != "main.version" { t.Fatalf("build version var = %q", file.Build.VersionVar) } - if !slices.Equal(file.Build.Targets, []BuildTarget{ - {OS: "linux", Arch: "amd64"}, - {OS: "darwin", Arch: "arm64"}, - }) { - t.Fatalf("build targets = %#v", file.Build.Targets) - } if file.SecretStore.BackendPolicy != "auto" { t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy) } diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 3ee85da..7a543f7 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -665,9 +665,6 @@ token_env_names = ["{{.ReleaseTokenEnv}}"] main_package = "./cmd/{{.BinaryName}}" output_dir = "build" version_var = "main.version" -[[build.targets]] -os = "linux" -arch = "amd64" [environment] known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index e7ad407..7425d65 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -90,9 +90,6 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "[environment]", "[profiles]", "version_var = \"main.version\"", - "[[build.targets]]", - "os = \"linux\"", - "arch = \"amd64\"", "backend_policy = \"auto\"", } { if !strings.Contains(string(manifestContent), snippet) { -- 2.45.2 From 7c239a7e9771fa00a0b01e3e4033d334ac824520 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 09:37:03 +0200 Subject: [PATCH 21/79] Revert "feat: add unified build command driven by mcp.toml" This reverts commit 845d20541b5f667ed7305d5045e025fd9e4b8af8. --- README.md | 34 ----- cmd/mcp-framework/build.go | 234 -------------------------------- cmd/mcp-framework/build_test.go | 155 --------------------- cmd/mcp-framework/main.go | 4 +- cmd/mcp-framework/main_test.go | 3 - manifest/manifest.go | 14 -- manifest/manifest_test.go | 14 -- scaffold/scaffold.go | 5 - scaffold/scaffold_test.go | 2 - 9 files changed, 1 insertion(+), 464 deletions(-) delete mode 100644 cmd/mcp-framework/build.go delete mode 100644 cmd/mcp-framework/build_test.go diff --git a/README.md b/README.md index 18502d8..f9145a8 100644 --- a/README.md +++ b/README.md @@ -38,36 +38,6 @@ go mod tidy go run ./cmd/my-mcp help ``` -## CLI de build unifiée - -Le binaire `mcp-framework` expose aussi une commande de build standardisée pour -les projets consommateurs : - -```bash -go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build -``` - -Par défaut la commande : - -- lit `mcp.toml` (en remontant les répertoires parents) -- récupère `binary_name` -- build `./cmd/` -- produit `build/--{.exe}` -- injecte la version via `-X main.version=` - (`VERSION` env, sinon `git describe`, sinon `dev`) - -Options principales : - -- `--manifest-dir` -- `--binary` -- `--package` -- `--build-dir` -- `--goos` -- `--goarch` -- `--version` -- `--version-var` -- `--ldflag` (répétable) - ## Packages - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. @@ -180,7 +150,6 @@ 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`. -- `[build]` : paramètres de build pour `mcp-framework build`. - `source_name` : nom humain de la source de release, utilisé dans certains messages d'erreur. - `driver` : driver de forge (`gitea`, `gitlab`, `github`) pour déduire automatiquement l'endpoint latest. @@ -193,9 +162,6 @@ Champs supportés : - `token_header` : header HTTP à utiliser pour l'authentification. - `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...). - `token_env_names` : liste de variables d'environnement candidates pour retrouver le token. -- `[build].main_package` : package main à builder (ex: `./cmd/my-mcp`). -- `[build].output_dir` : répertoire de sortie des artefacts (défaut `build`). -- `[build].version_var` : variable Go ciblée par `-X` pour injecter la version (`main.version`, `gitlab.../internal/app.Version`, etc.). - `[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. diff --git a/cmd/mcp-framework/build.go b/cmd/mcp-framework/build.go deleted file mode 100644 index de70f8d..0000000 --- a/cmd/mcp-framework/build.go +++ /dev/null @@ -1,234 +0,0 @@ -package main - -import ( - "errors" - "flag" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - - manifestpkg "gitea.lclr.dev/AI/mcp-framework/manifest" -) - -type stringListFlag []string - -func (f *stringListFlag) String() string { - return strings.Join(*f, ",") -} - -func (f *stringListFlag) Set(value string) error { - *f = append(*f, strings.TrimSpace(value)) - return nil -} - -func runBuild(args []string, stdout, stderr io.Writer) error { - if shouldShowHelp(args) { - printBuildHelp(stdout) - return nil - } - - fs := flag.NewFlagSet("build", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - defaultGOOS := strings.TrimSpace(os.Getenv("GOOS")) - if defaultGOOS == "" { - defaultGOOS = runtime.GOOS - } - - defaultGOARCH := strings.TrimSpace(os.Getenv("GOARCH")) - if defaultGOARCH == "" { - defaultGOARCH = runtime.GOARCH - } - - var manifestDir string - var binaryName string - var mainPackage string - var buildDir string - var goos string - var goarch string - var version string - var versionVar string - var gocache string - var extraLDFlags stringListFlag - - fs.StringVar(&manifestDir, "manifest-dir", ".", "Répertoire de départ pour trouver mcp.toml") - fs.StringVar(&binaryName, "binary", "", "Nom du binaire (override binary_name)") - fs.StringVar(&mainPackage, "package", "", "Package main à builder (override [build].main_package)") - fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") - fs.StringVar(&goos, "goos", defaultGOOS, "GOOS cible") - fs.StringVar(&goarch, "goarch", defaultGOARCH, "GOARCH cible") - fs.StringVar(&version, "version", "", "Version injectée (par défaut VERSION env, puis git describe, puis dev)") - fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") - fs.StringVar(&gocache, "gocache", strings.TrimSpace(os.Getenv("GOCACHE")), "Valeur GOCACHE pour go build") - fs.Var(&extraLDFlags, "ldflag", "Option additionnelle passée à -ldflags (répéter si nécessaire)") - - if err := fs.Parse(args); err != nil { - _ = stderr - return fmt.Errorf("parse build flags: %w", err) - } - - if fs.NArg() > 0 { - return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) - } - - file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) - if err != nil { - return err - } - - projectDir := filepath.Dir(manifestPath) - - binaryName = firstNonEmpty(binaryName, file.BinaryName) - if binaryName == "" { - return errors.New("binary name is required (set binary_name in mcp.toml or --binary)") - } - - mainPackage = firstNonEmpty(mainPackage, file.Build.MainPackage) - if mainPackage == "" { - mainPackage = fmt.Sprintf("./cmd/%s", binaryName) - } - - buildDir = firstNonEmpty(buildDir, file.Build.OutputDir, "build") - goos = firstNonEmpty(goos, runtime.GOOS) - goarch = firstNonEmpty(goarch, runtime.GOARCH) - version = resolveBuildVersion(version, projectDir) - versionVar = firstNonEmpty(versionVar, file.Build.VersionVar, "main.version") - - outputPath, err := buildOutputPath(projectDir, buildDir, binaryName, goos, goarch) - if err != nil { - return err - } - - outputDir := filepath.Dir(outputPath) - if err := os.MkdirAll(outputDir, 0o755); err != nil { - return fmt.Errorf("create build directory %q: %w", outputDir, err) - } - - ldflags := normalizeStringList(extraLDFlags) - if versionVar != "-" { - versionVar = strings.TrimSpace(versionVar) - if versionVar != "" { - ldflags = append([]string{fmt.Sprintf("-X %s=%s", versionVar, version)}, ldflags...) - } - } - - cmdArgs := []string{"build"} - if len(ldflags) > 0 { - cmdArgs = append(cmdArgs, "-ldflags", strings.Join(ldflags, " ")) - } - cmdArgs = append(cmdArgs, "-o", outputPath, mainPackage) - - cmd := exec.Command("go", cmdArgs...) - cmd.Dir = projectDir - cmd.Stdout = stdout - cmd.Stderr = stderr - cmd.Env = withEnvOverrides(os.Environ(), map[string]string{ - "GOOS": goos, - "GOARCH": goarch, - "GOCACHE": strings.TrimSpace(gocache), - }) - - if err := cmd.Run(); err != nil { - return fmt.Errorf("go build failed: %w", err) - } - - if _, err := fmt.Fprintf(stdout, "Build artifact: %s\n", outputPath); err != nil { - return err - } - - return nil -} - -func printBuildHelp(w io.Writer) { - fmt.Fprintf( - w, - "Usage:\n %s build [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n", - toolName, - ) -} - -func resolveBuildVersion(explicit, projectDir string) string { - if value := strings.TrimSpace(explicit); value != "" { - return value - } - if value := strings.TrimSpace(os.Getenv("VERSION")); value != "" { - return value - } - - describe := exec.Command("git", "describe", "--tags", "--always", "--dirty") - describe.Dir = projectDir - out, err := describe.Output() - if err == nil { - if value := strings.TrimSpace(string(out)); value != "" { - return value - } - } - - return "dev" -} - -func buildOutputPath(projectDir, buildDir, binaryName, goos, goarch string) (string, error) { - artifactName := fmt.Sprintf("%s-%s-%s", binaryName, goos, goarch) - if goos == "windows" { - artifactName += ".exe" - } - - dir := strings.TrimSpace(buildDir) - if dir == "" { - dir = "build" - } - - if filepath.IsAbs(dir) { - return filepath.Join(dir, artifactName), nil - } - if strings.TrimSpace(projectDir) == "" { - return "", errors.New("project directory is required") - } - return filepath.Join(projectDir, dir, artifactName), nil -} - -func withEnvOverrides(base []string, overrides map[string]string) []string { - result := make([]string, 0, len(base)+len(overrides)) - for _, entry := range base { - key, _, found := strings.Cut(entry, "=") - if !found { - continue - } - if _, overridden := overrides[key]; overridden { - continue - } - result = append(result, entry) - } - - for key, value := range overrides { - if strings.TrimSpace(value) == "" { - continue - } - result = append(result, key+"="+value) - } - - return result -} - -func normalizeStringList(values []string) []string { - normalized := make([]string, 0, len(values)) - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - normalized = append(normalized, trimmed) - } - } - return normalized -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} diff --git a/cmd/mcp-framework/build_test.go b/cmd/mcp-framework/build_test.go deleted file mode 100644 index 5fa1fd4..0000000 --- a/cmd/mcp-framework/build_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package main - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestRunBuildBuildsArtifactFromManifest(t *testing.T) { - projectDir := t.TempDir() - writeBuildFixture(t, projectDir, "demo-mcp", ` -module example.com/demo - -go 1.25.0 -`, ` -binary_name = "demo-mcp" - -[build] -main_package = "./cmd/demo-mcp" -output_dir = "build" -version_var = "main.version" -`, ` -package main - -import "fmt" - -var version = "dev" - -func main() { - fmt.Print(version) -} -`) - - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run([]string{ - "build", - "--manifest-dir", projectDir, - "--goos", runtime.GOOS, - "--goarch", runtime.GOARCH, - "--version", "1.2.3", - }, &stdout, &stderr) - if err != nil { - t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) - } - - artifactPath := artifactPath(projectDir, "build", "demo-mcp", runtime.GOOS, runtime.GOARCH) - if _, err := os.Stat(artifactPath); err != nil { - t.Fatalf("expected artifact at %s: %v", artifactPath, err) - } - - output, err := exec.Command(artifactPath).Output() - if err != nil { - t.Fatalf("run artifact: %v", err) - } - if strings.TrimSpace(string(output)) != "1.2.3" { - t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "1.2.3") - } -} - -func TestRunBuildUsesManifestVersionVar(t *testing.T) { - projectDir := t.TempDir() - writeBuildFixture(t, projectDir, "custom-version", ` -module example.com/custom-version - -go 1.25.0 -`, ` -binary_name = "custom-version" - -[build] -main_package = "./cmd/custom-version" -output_dir = "dist" -version_var = "main.releaseVersion" -`, ` -package main - -import "fmt" - -var releaseVersion = "dev" - -func main() { - fmt.Print(releaseVersion) -} -`) - - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run([]string{ - "build", - "--manifest-dir", projectDir, - "--goos", runtime.GOOS, - "--goarch", runtime.GOARCH, - "--version", "9.9.9", - }, &stdout, &stderr) - if err != nil { - t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) - } - - artifactPath := artifactPath(projectDir, "dist", "custom-version", runtime.GOOS, runtime.GOARCH) - output, err := exec.Command(artifactPath).Output() - if err != nil { - t.Fatalf("run artifact: %v", err) - } - if strings.TrimSpace(string(output)) != "9.9.9" { - t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "9.9.9") - } -} - -func TestBuildHelp(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - if err := run([]string{"build", "--help"}, &stdout, &stderr); err != nil { - t.Fatalf("run returned error: %v", err) - } - - output := stdout.String() - if !strings.Contains(output, "--manifest-dir") { - t.Fatalf("build help should mention --manifest-dir: %q", output) - } - if !strings.Contains(output, "version_var") { - t.Fatalf("build help should mention manifest build config: %q", output) - } -} - -func writeBuildFixture(t *testing.T, projectDir, binaryName, goModContent, manifestContent, mainContent string) { - t.Helper() - - if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(strings.TrimSpace(goModContent)+"\n"), 0o644); err != nil { - t.Fatalf("write go.mod: %v", err) - } - if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(strings.TrimSpace(manifestContent)+"\n"), 0o644); err != nil { - t.Fatalf("write mcp.toml: %v", err) - } - - cmdDir := filepath.Join(projectDir, "cmd", binaryName) - if err := os.MkdirAll(cmdDir, 0o755); err != nil { - t.Fatalf("mkdir cmd dir: %v", err) - } - if err := os.WriteFile(filepath.Join(cmdDir, "main.go"), []byte(strings.TrimSpace(mainContent)+"\n"), 0o644); err != nil { - t.Fatalf("write main.go: %v", err) - } -} - -func artifactPath(projectDir, outDir, binaryName, goos, goarch string) string { - name := binaryName + "-" + goos + "-" + goarch - if goos == "windows" { - name += ".exe" - } - return filepath.Join(projectDir, outDir, name) -} diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go index d380a8c..d6f98b9 100644 --- a/cmd/mcp-framework/main.go +++ b/cmd/mcp-framework/main.go @@ -34,8 +34,6 @@ func run(args []string, stdout, stderr io.Writer) error { } switch args[0] { - case "build": - return runBuild(args[1:], stdout, stderr) case "scaffold": return runScaffold(args[1:], stdout, stderr) default: @@ -144,7 +142,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error { func printGlobalHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s [options]\n\nCommands:\n build Build un binaire MCP de manière standardisée\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", + "Usage:\n %s [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", toolName, toolName, ) diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go index 1476bf7..6f32c6f 100644 --- a/cmd/mcp-framework/main_test.go +++ b/cmd/mcp-framework/main_test.go @@ -23,9 +23,6 @@ func TestRunPrintsGlobalHelp(t *testing.T) { if !strings.Contains(output, "scaffold init") { t.Fatalf("global help should mention scaffold init: %q", output) } - if !strings.Contains(output, "build") { - t.Fatalf("global help should mention build: %q", output) - } } func TestRunScaffoldInitCreatesProject(t *testing.T) { diff --git a/manifest/manifest.go b/manifest/manifest.go index b9238f3..b3c9414 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -18,7 +18,6 @@ type File struct { BinaryName string `toml:"binary_name"` DocsURL string `toml:"docs_url"` Update Update `toml:"update"` - Build Build `toml:"build"` Environment Environment `toml:"environment"` SecretStore SecretStore `toml:"secret_store"` Profiles Profiles `toml:"profiles"` @@ -39,12 +38,6 @@ type Update struct { TokenEnvNames []string `toml:"token_env_names"` } -type Build struct { - MainPackage string `toml:"main_package"` - OutputDir string `toml:"output_dir"` - VersionVar string `toml:"version_var"` -} - type Environment struct { Known []string `toml:"known"` } @@ -148,7 +141,6 @@ func (f *File) normalize() { f.BinaryName = strings.TrimSpace(f.BinaryName) f.DocsURL = strings.TrimSpace(f.DocsURL) f.Update.normalize() - f.Build.normalize() f.Environment.normalize() f.SecretStore.normalize() f.Profiles.normalize() @@ -168,12 +160,6 @@ func (u *Update) normalize() { u.TokenEnvNames = normalizeStringList(u.TokenEnvNames) } -func (b *Build) normalize() { - b.MainPackage = strings.TrimSpace(b.MainPackage) - b.OutputDir = strings.TrimSpace(b.OutputDir) - b.VersionVar = strings.TrimSpace(b.VersionVar) -} - func (e *Environment) normalize() { e.Known = normalizeStringList(e.Known) } diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 6ccf1d6..83ea7d5 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -151,11 +151,6 @@ docs_url = " https://docs.example.com/mcp " [update] latest_release_url = "https://example.com/latest" -[build] -main_package = " ./cmd/my-mcp " -output_dir = " build " -version_var = " main.version " - [environment] known = [" MCP_PROFILE ", "", "MCP_TOKEN"] @@ -188,15 +183,6 @@ description = " Client MCP interne " if !slices.Equal(file.Environment.Known, []string{"MCP_PROFILE", "MCP_TOKEN"}) { t.Fatalf("environment known = %v", file.Environment.Known) } - if file.Build.MainPackage != "./cmd/my-mcp" { - t.Fatalf("build main package = %q", file.Build.MainPackage) - } - if file.Build.OutputDir != "build" { - t.Fatalf("build output dir = %q", file.Build.OutputDir) - } - if file.Build.VersionVar != "main.version" { - t.Fatalf("build version var = %q", file.Build.VersionVar) - } if file.SecretStore.BackendPolicy != "auto" { t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy) } diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 7a543f7..bd2c96e 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -661,11 +661,6 @@ token_header = "Authorization" token_prefix = "token" token_env_names = ["{{.ReleaseTokenEnv}}"] -[build] -main_package = "./cmd/{{.BinaryName}}" -output_dir = "build" -version_var = "main.version" - [environment] known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 7425d65..c166a3b 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -85,11 +85,9 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "binary_name = \"my-mcp\"", "[update]", - "[build]", "[secret_store]", "[environment]", "[profiles]", - "version_var = \"main.version\"", "backend_policy = \"auto\"", } { if !strings.Contains(string(manifestContent), snippet) { -- 2.45.2 From f80eebb5753ac9f1e71e16f050790f24706dfd78 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 10:13:45 +0200 Subject: [PATCH 22/79] feat(scaffold): inject install.sh wizard in generated projects --- README.md | 3 +- cmd/mcp-framework/main_test.go | 3 + scaffold/scaffold.go | 201 +++++++++++++++++++++++++++++++-- scaffold/scaffold_test.go | 28 +++++ 4 files changed, 225 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f9145a8..243a2e5 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ go run ./cmd/my-mcp help - `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, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. -- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, wiring de base et README de démarrage). +- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage). - `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. @@ -191,6 +191,7 @@ _ = scaffoldInfo Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : - arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) +- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard setup/JSON MCP - wiring initial `bootstrap + config + secretstore + update` - `README.md` de démarrage diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go index 6f32c6f..0b17192 100644 --- a/cmd/mcp-framework/main_test.go +++ b/cmd/mcp-framework/main_test.go @@ -51,6 +51,9 @@ func TestRunScaffoldInitCreatesProject(t *testing.T) { if _, err := os.Stat(filepath.Join(target, "mcp.toml")); err != nil { t.Fatalf("generated mcp.toml missing: %v", err) } + if _, err := os.Stat(filepath.Join(target, "install.sh")); err != nil { + t.Fatalf("generated install.sh missing: %v", err) + } if !strings.Contains(stdout.String(), "Scaffold generated in") { t.Fatalf("stdout should include generation summary: %q", stdout.String()) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index bd2c96e..01eca44 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -70,18 +70,19 @@ func Generate(options Options) (Result, error) { } files := []generatedFile{ - {Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized)}, - {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized)}, - {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized)}, - {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized)}, - {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized)}, - {Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized)}, + {Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized), Mode: 0o644}, + {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized), Mode: 0o644}, + {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized), Mode: 0o644}, + {Path: "install.sh", Content: renderTemplate(installTemplate, normalized), Mode: 0o755}, + {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized), Mode: 0o644}, + {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized), Mode: 0o644}, + {Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized), Mode: 0o644}, } written := make([]string, 0, len(files)) for _, file := range files { fullPath := filepath.Join(normalized.TargetDir, file.Path) - if err := writeFile(fullPath, file.Content, normalized.Overwrite); err != nil { + if err := writeFile(fullPath, file.Content, file.Mode, normalized.Overwrite); err != nil { return Result{}, err } written = append(written, file.Path) @@ -97,9 +98,10 @@ func Generate(options Options) (Result, error) { type generatedFile struct { Path string Content string + Mode os.FileMode } -func writeFile(path, content string, overwrite bool) error { +func writeFile(path, content string, mode os.FileMode, overwrite bool) error { if !overwrite { if _, err := os.Stat(path); err == nil { return fmt.Errorf("%w: %s", ErrFileExists, path) @@ -113,7 +115,11 @@ func writeFile(path, content string, overwrite bool) error { return fmt.Errorf("create scaffold directory %q: %w", dir, err) } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + if mode == 0 { + mode = 0o644 + } + + if err := os.WriteFile(path, []byte(content), mode); err != nil { return fmt.Errorf("write scaffold file %q: %w", path, err) } @@ -326,6 +332,175 @@ const goModTemplate = `module {{.ModulePath}} go 1.25.0 ` +const installTemplate = `#!/usr/bin/env bash +set -euo pipefail + +BINARY_NAME="{{.BinaryName}}" +MODULE_PATH="{{.ModulePath}}" +DEFAULT_PROFILE="{{.DefaultProfile}}" +PROFILE_ENV="{{.ProfileEnv}}" + +prompt() { + local label="$1" + local default_value="$2" + local answer="" + + if [ -n "$default_value" ]; then + printf "%s [%s]: " "$label" "$default_value" + else + printf "%s: " "$label" + fi + + if [ -r /dev/tty ]; then + IFS= read -r answer < /dev/tty || answer="" + else + IFS= read -r answer || answer="" + fi + + if [ -z "$answer" ]; then + printf "%s" "$default_value" + return + fi + + printf "%s" "$answer" +} + +go_bin_dir() { + local gobin + gobin="$(go env GOBIN 2>/dev/null || true)" + if [ -n "$gobin" ]; then + printf "%s\n" "$gobin" + return + fi + + go env GOPATH 2>/dev/null | awk '{print $1 "/bin"}' +} + +resolve_binary_path() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + command -v "$BINARY_NAME" + return + fi + + if command -v go >/dev/null 2>&1; then + local bin_dir + bin_dir="$(go_bin_dir)" + if [ -n "$bin_dir" ] && [ -x "$bin_dir/$BINARY_NAME" ]; then + printf "%s\n" "$bin_dir/$BINARY_NAME" + return + fi + fi + + printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" +} + +install_binary() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + printf "Binaire détecté: %s\n" "$(command -v "$BINARY_NAME")" + return + fi + + if ! command -v go >/dev/null 2>&1; then + printf "Go n'est pas installé. Installe Go ou utilise l'option JSON.\n" >&2 + exit 1 + fi + + printf "Installation du binaire via go install...\n" + go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest" + + local bin_dir + bin_dir="$(go_bin_dir)" + if [ -n "$bin_dir" ]; then + printf "Binaire installé dans %s\n" "$bin_dir" + printf "Ajoute ce dossier à ton PATH si nécessaire.\n" + fi +} + +run_setup_wizard() { + install_binary + + local profile + profile="$(prompt "Profil à configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")" + local binary_path + binary_path="$(resolve_binary_path)" + + printf "Lancement de %s setup...\n\n" "$BINARY_NAME" + if [ -r /dev/tty ] && [ -w /dev/tty ]; then + env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty + else + env "${PROFILE_ENV}=${profile}" "$binary_path" setup + fi +} + +print_mcp_json() { + local profile + profile="$(prompt "Profil à exposer dans la config MCP (${PROFILE_ENV})" "$DEFAULT_PROFILE")" + local default_command + default_command="$(resolve_binary_path)" + local command_path + command_path="$(prompt "Commande du serveur MCP" "$default_command")" + + cat <&2 + ;; + esac + done +} + +main "$@" +` + const mainTemplate = `package main import ( @@ -691,6 +866,7 @@ Binaire MCP généré depuis ` + "`mcp-framework`" + `. │ └── app.go ├── .gitignore ├── go.mod +├── install.sh ├── mcp.toml └── README.md ` + "```" + ` @@ -727,9 +903,16 @@ go run ./cmd/{{.BinaryName}} mcp go run ./cmd/{{.BinaryName}} config test ` + "```" + ` +6. Publier un install wizard consommable via ` + "`curl | bash`" + ` : + +` + "```bash" + ` +curl -fsSL https://///raw/branch/main/install.sh | bash +` + "```" + ` + ## Points à adapter - Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs). +- Adapter l'URL ` + "`curl .../install.sh`" + ` à votre forge/répertoire. - Compléter la logique métier dans ` + "`internal/app/app.go`" + ` (` + "`runMCP`" + `). - Ajuster les variables d’environnement connues si besoin. ` diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index c166a3b..98ef6f9 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -35,6 +35,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "README.md", "cmd/my-mcp/main.go", "go.mod", + "install.sh", "internal/app/app.go", "mcp.toml", } @@ -102,12 +103,39 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "Arborescence générée", "go run ./cmd/my-mcp setup", + "curl -fsSL https://///raw/branch/main/install.sh | bash", "internal/app/app.go", } { if !strings.Contains(string(readme), snippet) { t.Fatalf("README missing snippet %q", snippet) } } + + installScriptPath := filepath.Join(target, "install.sh") + installScript, err := os.ReadFile(installScriptPath) + if err != nil { + t.Fatalf("ReadFile install.sh: %v", err) + } + for _, snippet := range []string{ + "#!/usr/bin/env bash", + `MODULE_PATH="example.com/acme/my-mcp"`, + `go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`, + "config MCP (Codex)", + "config MCP (Claude Desktop)", + `"${PROFILE_ENV}=${profile}"`, + } { + if !strings.Contains(string(installScript), snippet) { + t.Fatalf("install.sh missing snippet %q", snippet) + } + } + + info, err := os.Stat(installScriptPath) + if err != nil { + t.Fatalf("Stat install.sh: %v", err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("install.sh mode = %o, want 755", info.Mode().Perm()) + } } func TestGenerateUsesDefaultsFromTargetDirectory(t *testing.T) { -- 2.45.2 From b2eebf413eb0c08bcccaab4c0194247b04fb878a Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 10:21:53 +0200 Subject: [PATCH 23/79] fix(scaffold): avoid prompt capture in install wizard --- scaffold/scaffold.go | 4 ++-- scaffold/scaffold_test.go | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 01eca44..1b737a2 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -346,9 +346,9 @@ prompt() { local answer="" if [ -n "$default_value" ]; then - printf "%s [%s]: " "$label" "$default_value" + printf "%s [%s]: " "$label" "$default_value" >&2 else - printf "%s: " "$label" + printf "%s: " "$label" >&2 fi if [ -r /dev/tty ]; then diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 98ef6f9..df74d60 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -120,6 +120,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "#!/usr/bin/env bash", `MODULE_PATH="example.com/acme/my-mcp"`, `go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`, + `printf "%s [%s]: " "$label" "$default_value" >&2`, "config MCP (Codex)", "config MCP (Claude Desktop)", `"${PROFILE_ENV}=${profile}"`, -- 2.45.2 From 9d862c876f561b695911ba40ffa27ed41151f662 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 10:33:13 +0200 Subject: [PATCH 24/79] feat(scaffold): add TUI install wizard for Claude and Codex --- README.md | 2 +- scaffold/scaffold.go | 295 ++++++++++++++++++++++++++++++++------ scaffold/scaffold_test.go | 12 +- 3 files changed, 264 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 243a2e5..dad269e 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ _ = scaffoldInfo Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : - arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) -- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard setup/JSON MCP +- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP) - wiring initial `bootstrap + config + secretstore + update` - `README.md` de démarrage diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 1b737a2..852b61c 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -340,19 +340,67 @@ MODULE_PATH="{{.ModulePath}}" DEFAULT_PROFILE="{{.DefaultProfile}}" PROFILE_ENV="{{.ProfileEnv}}" +if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then + C_RESET="$(printf '\033[0m')" + C_BOLD="$(printf '\033[1m')" + C_DIM="$(printf '\033[2m')" + C_RED="$(printf '\033[31m')" + C_GREEN="$(printf '\033[32m')" + C_YELLOW="$(printf '\033[33m')" + C_BLUE="$(printf '\033[34m')" + C_MAGENTA="$(printf '\033[35m')" + C_CYAN="$(printf '\033[36m')" +else + C_RESET="" + C_BOLD="" + C_DIM="" + C_RED="" + C_GREEN="" + C_YELLOW="" + C_BLUE="" + C_MAGENTA="" + C_CYAN="" +fi + +ui_line() { + printf "%b%s%b\n" "$C_DIM" "------------------------------------------------------------" "$C_RESET" >&2 +} + +ui_title() { + printf "\n%b%s%b\n" "$C_BOLD$C_CYAN" "$1" "$C_RESET" >&2 +} + +ui_info() { + printf "%b[info]%b %s\n" "$C_BLUE" "$C_RESET" "$1" >&2 +} + +ui_success() { + printf "%b[ok]%b %s\n" "$C_GREEN" "$C_RESET" "$1" >&2 +} + +ui_warn() { + printf "%b[warn]%b %s\n" "$C_YELLOW" "$C_RESET" "$1" >&2 +} + +ui_error() { + printf "%b[error]%b %s\n" "$C_RED" "$C_RESET" "$1" >&2 +} + prompt() { local label="$1" local default_value="$2" local answer="" if [ -n "$default_value" ]; then - printf "%s [%s]: " "$label" "$default_value" >&2 + printf "%b%s%b [%s]: " "$C_BOLD" "$label" "$C_RESET" "$default_value" >&2 else - printf "%s: " "$label" >&2 + printf "%b%s%b: " "$C_BOLD" "$label" "$C_RESET" >&2 fi - if [ -r /dev/tty ]; then - IFS= read -r answer < /dev/tty || answer="" + if [ -t 2 ] && [ -r /dev/tty ]; then + if ! IFS= read -r answer < /dev/tty 2>/dev/null; then + IFS= read -r answer || answer="" + fi else IFS= read -r answer || answer="" fi @@ -365,6 +413,20 @@ prompt() { printf "%s" "$answer" } +sanitize_server_name() { + local raw="$1" + local sanitized + sanitized="$(printf "%s" "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g; s/--*/-/g; s/^-*//; s/-*$//')" + if [ -z "$sanitized" ]; then + sanitized="$BINARY_NAME" + fi + printf "%s" "$sanitized" +} + +toml_escape() { + printf "%s" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + go_bin_dir() { local gobin gobin="$(go env GOBIN 2>/dev/null || true)" @@ -394,25 +456,43 @@ resolve_binary_path() { printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" } -install_binary() { - if command -v "$BINARY_NAME" >/dev/null 2>&1; then - printf "Binaire détecté: %s\n" "$(command -v "$BINARY_NAME")" +ensure_cli() { + local cli_name="$1" + if command -v "$cli_name" >/dev/null 2>&1; then return fi + ui_error "Commande introuvable: $cli_name" + exit 1 +} + +install_binary() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + ui_success "Binaire detecte: $(command -v "$BINARY_NAME")" + local reinstall + reinstall="$(prompt "Reinstaller via go install ? (y/N)" "N")" + case "$reinstall" in + y|Y|yes|YES) + ;; + *) + return + ;; + esac + fi + if ! command -v go >/dev/null 2>&1; then - printf "Go n'est pas installé. Installe Go ou utilise l'option JSON.\n" >&2 + ui_error "Go n'est pas installe. Installe Go ou choisis une configuration manuelle." exit 1 fi - printf "Installation du binaire via go install...\n" + ui_info "Installation du binaire via go install..." go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest" local bin_dir bin_dir="$(go_bin_dir)" if [ -n "$bin_dir" ]; then - printf "Binaire installé dans %s\n" "$bin_dir" - printf "Ajoute ce dossier à ton PATH si nécessaire.\n" + ui_success "Binaire installe dans $bin_dir" + ui_info "Ajoute ce dossier au PATH si necessaire." fi } @@ -420,34 +500,161 @@ run_setup_wizard() { install_binary local profile - profile="$(prompt "Profil à configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")" + profile="$(prompt "Profil a configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")" local binary_path binary_path="$(resolve_binary_path)" - printf "Lancement de %s setup...\n\n" "$BINARY_NAME" + ui_info "Lancement de $BINARY_NAME setup" if [ -r /dev/tty ] && [ -w /dev/tty ]; then env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty else env "${PROFILE_ENV}=${profile}" "$binary_path" setup fi + ui_success "Setup termine pour le profil \"$profile\"." +} + +collect_server_inputs() { + local default_name + default_name="$(sanitize_server_name "$BINARY_NAME")" + SERVER_NAME="$(prompt "Nom du serveur MCP" "$default_name")" + SERVER_NAME="$(sanitize_server_name "$SERVER_NAME")" + + PROFILE_VALUE="$(prompt "Valeur de ${PROFILE_ENV}" "$DEFAULT_PROFILE")" + + local default_command + default_command="$(resolve_binary_path)" + COMMAND_PATH="$(prompt "Chemin du binaire serveur MCP" "$default_command")" +} + +choose_scope() { + local selected + while true; do + ui_title "Scope de configuration" + printf " 1) global (user)\n" >&2 + printf " 2) project (projet courant)\n" >&2 + selected="$(prompt "Choix" "1")" + case "$selected" in + 1) + printf "global" + return + ;; + 2) + printf "project" + return + ;; + *) + ui_warn "Choix invalide: $selected" + ;; + esac + done +} + +apply_claude_mcp() { + ensure_cli "claude" + collect_server_inputs + + local scope_choice + scope_choice="$(choose_scope)" + local claude_scope + if [ "$scope_choice" = "global" ]; then + claude_scope="user" + else + claude_scope="project" + fi + + ui_info "Application de la configuration Claude ($claude_scope)..." + claude mcp remove --scope "$claude_scope" "$SERVER_NAME" >/dev/null 2>&1 || true + claude mcp add \ + --transport stdio \ + --scope "$claude_scope" \ + -e "${PROFILE_ENV}=${PROFILE_VALUE}" \ + "$SERVER_NAME" -- "$COMMAND_PATH" mcp + + ui_success "Serveur \"$SERVER_NAME\" configure dans Claude ($claude_scope)." +} + +rewrite_codex_project_config() { + local project_dir="$1" + local config_file="$project_dir/.codex/config.toml" + local section_prefix="[mcp_servers.${SERVER_NAME}" + + mkdir -p "$project_dir/.codex" + touch "$config_file" + + local tmp_file + tmp_file="$(mktemp)" + + awk -v prefix="$section_prefix" ' + function is_target(line, p, next_char) { + if (index(line, p) != 1) { + return 0 + } + next_char = substr(line, length(p) + 1, 1) + return next_char == "]" || next_char == "." + } + /^\[.*\]$/ { + if (is_target($0, prefix)) { + skip = 1 + next + } + if (skip == 1) { + skip = 0 + } + } + { + if (skip != 1) { + print $0 + } + } + ' "$config_file" > "$tmp_file" + + mv "$tmp_file" "$config_file" + + { + printf "\n[mcp_servers.%s]\n" "$SERVER_NAME" + printf "command = \"%s\"\n" "$(toml_escape "$COMMAND_PATH")" + printf "args = [\"mcp\"]\n\n" + printf "[mcp_servers.%s.env]\n" "$SERVER_NAME" + printf "%s = \"%s\"\n" "$PROFILE_ENV" "$(toml_escape "$PROFILE_VALUE")" + } >> "$config_file" +} + +apply_codex_mcp() { + ensure_cli "codex" + collect_server_inputs + + local scope_choice + scope_choice="$(choose_scope)" + + if [ "$scope_choice" = "global" ]; then + ui_info "Application via codex mcp add (scope global)..." + codex mcp remove "$SERVER_NAME" >/dev/null 2>&1 || true + codex mcp add \ + "$SERVER_NAME" \ + --env "${PROFILE_ENV}=${PROFILE_VALUE}" \ + -- "$COMMAND_PATH" mcp + ui_success "Serveur \"$SERVER_NAME\" configure dans le scope global Codex." + return + fi + + local default_project_dir + default_project_dir="$(pwd)" + local project_dir + project_dir="$(prompt "Dossier projet cible pour .codex/config.toml" "$default_project_dir")" + rewrite_codex_project_config "$project_dir" + ui_success "Configuration projet ecrite dans $project_dir/.codex/config.toml" } print_mcp_json() { - local profile - profile="$(prompt "Profil à exposer dans la config MCP (${PROFILE_ENV})" "$DEFAULT_PROFILE")" - local default_command - default_command="$(resolve_binary_path)" - local command_path - command_path="$(prompt "Commande du serveur MCP" "$default_command")" - + collect_server_inputs cat <&2 + printf "%bFramework module:%b %s\n" "$C_DIM" "$C_RESET" "$MODULE_PATH" >&2 + ui_line + printf "Choisis une action:\n" >&2 + printf " 1) Installer/mettre a jour le binaire + setup\n" >&2 + printf " 2) Configurer Claude Code (apply direct)\n" >&2 + printf " 3) Configurer Codex (apply direct)\n" >&2 + printf " 4) Generer JSON MCP manuel\n" >&2 + printf " 5) Quitter\n" >&2 } main() { @@ -474,25 +680,32 @@ main() { print_header local choice choice="$(prompt "Choix" "1")" - printf "\n" + printf "\n" >&2 case "$choice" in 1) run_setup_wizard - printf "\nInstallation terminée.\n" return ;; - 2|3|4) - printf "Copie ce JSON dans la config MCP du client ciblé.\n\n" + 2) + apply_claude_mcp + return + ;; + 3) + apply_codex_mcp + return + ;; + 4) + ui_info "JSON MCP genere sur stdout." print_mcp_json return ;; 5) - printf "Annulé.\n" + ui_warn "Annule." return ;; *) - printf "Choix invalide: %s\n\n" "$choice" >&2 + ui_warn "Choix invalide: $choice" ;; esac done @@ -909,6 +1122,8 @@ go run ./cmd/{{.BinaryName}} config test curl -fsSL https://///raw/branch/main/install.sh | bash ` + "```" + ` +Le wizard permet ensuite d'appliquer directement la configuration MCP pour Claude Code ou Codex (scope global/projet), ou de générer un JSON manuel. + ## Points à adapter - Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs). diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index df74d60..5898c7a 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -120,10 +120,14 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "#!/usr/bin/env bash", `MODULE_PATH="example.com/acme/my-mcp"`, `go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`, - `printf "%s [%s]: " "$label" "$default_value" >&2`, - "config MCP (Codex)", - "config MCP (Claude Desktop)", - `"${PROFILE_ENV}=${profile}"`, + "MCP Install Wizard", + `claude mcp add \`, + `--transport stdio \`, + `--scope "$claude_scope" \`, + `codex mcp add \`, + `Dossier projet cible pour .codex/config.toml`, + `[mcp_servers.%s]`, + `"${PROFILE_ENV}=${PROFILE_VALUE}"`, } { if !strings.Contains(string(installScript), snippet) { t.Fatalf("install.sh missing snippet %q", snippet) -- 2.45.2 From 3eeb2fe17358f5777039f43fcc53e7ba44a773cf Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 11:29:45 +0200 Subject: [PATCH 25/79] feat(scaffold): align generated install wizard with latest TUI flow --- scaffold/scaffold.go | 244 ++++++++++++++++++++++++++++---------- scaffold/scaffold_test.go | 4 + 2 files changed, 185 insertions(+), 63 deletions(-) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 852b61c..918fdc9 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -339,6 +339,9 @@ BINARY_NAME="{{.BinaryName}}" MODULE_PATH="{{.ModulePath}}" DEFAULT_PROFILE="{{.DefaultProfile}}" PROFILE_ENV="{{.ProfileEnv}}" +PREFILL_SERVER_NAME="" +PREFILL_PROFILE_VALUE="" +PREFILL_COMMAND_PATH="" if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then C_RESET="$(printf '\033[0m')" @@ -413,6 +416,101 @@ prompt() { printf "%s" "$answer" } +tty_prompt_available() { + [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ] +} + +menu_select() { + local title="$1" + shift + local options=("$@") + local count="${#options[@]}" + local index=0 + local key="" + local i=0 + local rows=$((count + 3)) + local rendered=0 + + if [ "$count" -eq 0 ]; then + return 1 + fi + + if ! tty_prompt_available; then + ui_title "$title" + i=0 + while [ "$i" -lt "$count" ]; do + printf " %d) %s\n" "$((i + 1))" "${options[$i]}" >&2 + i=$((i + 1)) + done + + while true; do + local raw_choice + raw_choice="$(prompt "Choix" "1")" + case "$raw_choice" in + ''|*[!0-9]*) + ui_warn "Choix invalide: $raw_choice" + ;; + *) + if [ "$raw_choice" -ge 1 ] && [ "$raw_choice" -le "$count" ]; then + printf "%s" "${options[$((raw_choice - 1))]}" + return 0 + fi + ui_warn "Choix invalide: $raw_choice" + ;; + esac + done + fi + + while true; do + if [ "$rendered" -eq 1 ]; then + printf "\033[%dA\033[J" "$rows" >&2 2>/dev/null || true + fi + ui_title "$title" + i=0 + while [ "$i" -lt "$count" ]; do + if [ "$i" -eq "$index" ]; then + printf " %b› %s%b\n" "$C_BOLD$C_CYAN" "${options[$i]}" "$C_RESET" >&2 + else + printf " %s\n" "${options[$i]}" >&2 + fi + i=$((i + 1)) + done + printf "%bUtilise ↑/↓ puis Entrée.%b\n" "$C_DIM" "$C_RESET" >&2 + rendered=1 + + if ! IFS= read -rsn1 key < /dev/tty; then + continue + fi + + case "$key" in + "") + printf "%s" "${options[$index]}" + return 0 + ;; + $'\x1b') + if IFS= read -rsn2 key < /dev/tty; then + case "$key" in + "[A") + if [ "$index" -eq 0 ]; then + index=$((count - 1)) + else + index=$((index - 1)) + fi + ;; + "[B") + if [ "$index" -eq $((count - 1)) ]; then + index=0 + else + index=$((index + 1)) + fi + ;; + esac + fi + ;; + esac + done +} + sanitize_server_name() { local raw="$1" local sanitized @@ -505,48 +603,46 @@ run_setup_wizard() { binary_path="$(resolve_binary_path)" ui_info "Lancement de $BINARY_NAME setup" - if [ -r /dev/tty ] && [ -w /dev/tty ]; then + if [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty else env "${PROFILE_ENV}=${profile}" "$binary_path" setup fi ui_success "Setup termine pour le profil \"$profile\"." + + PREFILL_SERVER_NAME="$(sanitize_server_name "$BINARY_NAME")" + PREFILL_PROFILE_VALUE="$profile" + PREFILL_COMMAND_PATH="$binary_path" } collect_server_inputs() { local default_name - default_name="$(sanitize_server_name "$BINARY_NAME")" + default_name="$(sanitize_server_name "${PREFILL_SERVER_NAME:-$BINARY_NAME}")" SERVER_NAME="$(prompt "Nom du serveur MCP" "$default_name")" SERVER_NAME="$(sanitize_server_name "$SERVER_NAME")" - PROFILE_VALUE="$(prompt "Valeur de ${PROFILE_ENV}" "$DEFAULT_PROFILE")" + PROFILE_VALUE="$(prompt "Valeur de ${PROFILE_ENV}" "${PREFILL_PROFILE_VALUE:-$DEFAULT_PROFILE}")" local default_command - default_command="$(resolve_binary_path)" + if [ -n "${PREFILL_COMMAND_PATH:-}" ]; then + default_command="$PREFILL_COMMAND_PATH" + else + default_command="$(resolve_binary_path)" + fi COMMAND_PATH="$(prompt "Chemin du binaire serveur MCP" "$default_command")" } choose_scope() { local selected - while true; do - ui_title "Scope de configuration" - printf " 1) global (user)\n" >&2 - printf " 2) project (projet courant)\n" >&2 - selected="$(prompt "Choix" "1")" - case "$selected" in - 1) - printf "global" - return - ;; - 2) - printf "project" - return - ;; - *) - ui_warn "Choix invalide: $selected" - ;; - esac - done + selected="$(menu_select "Scope de configuration" "global (user)" "project (projet courant)")" + case "$selected" in + "global (user)") + printf "global" + ;; + *) + printf "project" + ;; + esac } apply_claude_mcp() { @@ -567,8 +663,9 @@ apply_claude_mcp() { claude mcp add \ --transport stdio \ --scope "$claude_scope" \ - -e "${PROFILE_ENV}=${PROFILE_VALUE}" \ - "$SERVER_NAME" -- "$COMMAND_PATH" mcp + "$SERVER_NAME" \ + --env "${PROFILE_ENV}=${PROFILE_VALUE}" \ + -- "$COMMAND_PATH" mcp ui_success "Serveur \"$SERVER_NAME\" configure dans Claude ($claude_scope)." } @@ -667,48 +764,69 @@ print_header() { printf "%bMCP Install Wizard%b for %b%s%b\n" "$C_BOLD$C_MAGENTA" "$C_RESET" "$C_BOLD" "$BINARY_NAME" "$C_RESET" >&2 printf "%bFramework module:%b %s\n" "$C_DIM" "$C_RESET" "$MODULE_PATH" >&2 ui_line - printf "Choisis une action:\n" >&2 - printf " 1) Installer/mettre a jour le binaire + setup\n" >&2 - printf " 2) Configurer Claude Code (apply direct)\n" >&2 - printf " 3) Configurer Codex (apply direct)\n" >&2 - printf " 4) Generer JSON MCP manuel\n" >&2 - printf " 5) Quitter\n" >&2 + printf "%bSelectionne une action dans le menu interactif.%b\n" "$C_DIM" "$C_RESET" >&2 +} + +post_setup_configure_mcp() { + ui_title "Configuration MCP apres setup" + local next_action + next_action="$(menu_select \ + "Configurer le MCP maintenant ?" \ + "Configurer Claude Code (apply direct)" \ + "Configurer Codex (apply direct)" \ + "Generer JSON MCP manuel" \ + "Terminer sans config MCP")" + printf "\n" >&2 + + case "$next_action" in + "Configurer Claude Code (apply direct)") + apply_claude_mcp + ;; + "Configurer Codex (apply direct)") + apply_codex_mcp + ;; + "Generer JSON MCP manuel") + ui_info "JSON MCP genere sur stdout." + print_mcp_json + ;; + *) + ui_info "Setup termine sans configuration MCP additionnelle." + ;; + esac } main() { - while true; do - print_header - local choice - choice="$(prompt "Choix" "1")" - printf "\n" >&2 + print_header - case "$choice" in - 1) - run_setup_wizard - return - ;; - 2) - apply_claude_mcp - return - ;; - 3) - apply_codex_mcp - return - ;; - 4) - ui_info "JSON MCP genere sur stdout." - print_mcp_json - return - ;; - 5) - ui_warn "Annule." - return - ;; - *) - ui_warn "Choix invalide: $choice" - ;; - esac - done + local action + action="$(menu_select \ + "Choisis une action" \ + "Installer/mettre a jour le binaire + setup" \ + "Configurer Claude Code (apply direct)" \ + "Configurer Codex (apply direct)" \ + "Generer JSON MCP manuel" \ + "Quitter")" + printf "\n" >&2 + + case "$action" in + "Installer/mettre a jour le binaire + setup") + run_setup_wizard + post_setup_configure_mcp + ;; + "Configurer Claude Code (apply direct)") + apply_claude_mcp + ;; + "Configurer Codex (apply direct)") + apply_codex_mcp + ;; + "Generer JSON MCP manuel") + ui_info "JSON MCP genere sur stdout." + print_mcp_json + ;; + *) + ui_warn "Annule." + ;; + esac } main "$@" diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 5898c7a..6749caa 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -121,9 +121,13 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { `MODULE_PATH="example.com/acme/my-mcp"`, `go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`, "MCP Install Wizard", + `menu_select() {`, + `Utilise ↑/↓ puis Entrée.`, + `Configurer le MCP maintenant ?`, `claude mcp add \`, `--transport stdio \`, `--scope "$claude_scope" \`, + `--env "${PROFILE_ENV}=${PROFILE_VALUE}" \`, `codex mcp add \`, `Dossier projet cible pour .codex/config.toml`, `[mcp_servers.%s]`, -- 2.45.2 From 0d266cd5cc4210d74789b9c6177d8155a0cc7fe5 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 12:13:41 +0200 Subject: [PATCH 26/79] =?UTF-8?q?fix:=20durcir=20le=20scaffold=20runtime?= =?UTF-8?q?=20et=20la=20s=C3=A9curit=C3=A9=20des=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 25 ++++++++++++++++++ scaffold/scaffold.go | 55 ++++++++++++++++++++++++++------------- scaffold/scaffold_test.go | 3 +++ update/update.go | 34 +++++++++++++++++++++--- update/update_test.go | 39 +++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..11be548 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +"on": + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run go test + run: go test ./... + + - name: Run go vet + run: go vet ./... diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 918fdc9..c91ac45 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -70,19 +70,24 @@ func Generate(options Options) (Result, error) { } files := []generatedFile{ - {Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized), Mode: 0o644}, - {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized), Mode: 0o644}, - {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized), Mode: 0o644}, - {Path: "install.sh", Content: renderTemplate(installTemplate, normalized), Mode: 0o755}, - {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized), Mode: 0o644}, - {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized), Mode: 0o644}, - {Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized), Mode: 0o644}, + {Path: ".gitignore", Template: gitignoreTemplate, Mode: 0o644}, + {Path: "go.mod", Template: goModTemplate, Mode: 0o644}, + {Path: "README.md", Template: readmeTemplate, Mode: 0o644}, + {Path: "install.sh", Template: installTemplate, Mode: 0o755}, + {Path: "mcp.toml", Template: manifestTemplate, Mode: 0o644}, + {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Template: mainTemplate, Mode: 0o644}, + {Path: filepath.Join("internal", "app", "app.go"), Template: appTemplate, Mode: 0o644}, } written := make([]string, 0, len(files)) for _, file := range files { + content, err := renderTemplate(file.Template, normalized) + if err != nil { + return Result{}, fmt.Errorf("render scaffold file %q: %w", file.Path, err) + } + fullPath := filepath.Join(normalized.TargetDir, file.Path) - if err := writeFile(fullPath, file.Content, file.Mode, normalized.Overwrite); err != nil { + if err := writeFile(fullPath, content, file.Mode, normalized.Overwrite); err != nil { return Result{}, err } written = append(written, file.Path) @@ -96,9 +101,9 @@ func Generate(options Options) (Result, error) { } type generatedFile struct { - Path string - Content string - Mode os.FileMode + Path string + Template string + Mode os.FileMode } func writeFile(path, content string, mode os.FileMode, overwrite bool) error { @@ -126,15 +131,18 @@ func writeFile(path, content string, mode os.FileMode, overwrite bool) error { return nil } -func renderTemplate(src string, data normalizedOptions) string { - tpl := template.Must(template.New("scaffold").Parse(src)) +func renderTemplate(src string, data normalizedOptions) (string, error) { + tpl, err := template.New("scaffold").Parse(src) + if err != nil { + return "", fmt.Errorf("parse template: %w", err) + } var builder strings.Builder if err := tpl.Execute(&builder, data); err != nil { - panic(err) + return "", fmt.Errorf("execute template: %w", err) } - return builder.String() + return builder.String(), nil } func normalizeOptions(options Options) (normalizedOptions, error) { @@ -859,6 +867,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" "gitea.lclr.dev/AI/mcp-framework/bootstrap" @@ -895,9 +904,19 @@ func Run(ctx context.Context, args []string, version string) error { } func NewRuntime(version string) (Runtime, error) { - manifestFile, _, err := manifest.LoadDefault(".") + manifestStartDir := "." + if executablePath, err := os.Executable(); err == nil { + if dir := strings.TrimSpace(filepath.Dir(executablePath)); dir != "" { + manifestStartDir = dir + } + } + + manifestFile, _, err := manifest.LoadDefault(manifestStartDir) if err != nil { - return Runtime{}, err + if !errors.Is(err, os.ErrNotExist) { + return Runtime{}, err + } + manifestFile = manifest.File{} } bootstrapInfo := manifestFile.BootstrapInfo() @@ -1162,7 +1181,7 @@ repository = "{{.ReleaseRepository}}" base_url = "{{.ReleaseBaseURL}}" asset_name_template = "{binary}-{os}-{arch}{ext}" checksum_asset_name = "{asset}.sha256" -checksum_required = false +checksum_required = true token_header = "Authorization" token_prefix = "token" token_env_names = ["{{.ReleaseTokenEnv}}"] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 6749caa..bf72730 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -70,6 +70,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "update.Run", "manifest.LoadDefault", "bootstrap.Run", + "os.Executable()", + "errors.Is(err, os.ErrNotExist)", } { if !strings.Contains(string(appGo), snippet) { t.Fatalf("app.go missing snippet %q", snippet) @@ -86,6 +88,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "binary_name = \"my-mcp\"", "[update]", + "checksum_required = true", "[secret_store]", "[environment]", "[profiles]", diff --git a/update/update.go b/update/update.go index 7ba393a..6c5dbfb 100644 --- a/update/update.go +++ b/update/update.go @@ -20,6 +20,7 @@ import ( ) const defaultAssetNameTemplate = "{binary}-{os}-{arch}{ext}" +const defaultMaxDownloadBytes int64 = 200 * 1024 * 1024 type Options struct { Client *http.Client @@ -32,6 +33,7 @@ type Options struct { ReleaseSource ReleaseSource GOOS string GOARCH string + MaxDownloadBytes int64 ValidateDownloaded ValidateDownloadedFunc ReplaceExecutable ReplaceExecutableFunc } @@ -124,6 +126,9 @@ func Run(ctx context.Context, opts Options) error { if strings.TrimSpace(opts.GOARCH) == "" { opts.GOARCH = runtime.GOARCH } + if opts.MaxDownloadBytes <= 0 { + opts.MaxDownloadBytes = defaultMaxDownloadBytes + } source := normalizeSource(opts.ReleaseSource) auth := ResolveAuth(source.Token, source) @@ -162,7 +167,7 @@ func Run(ctx context.Context, opts Options) error { return err } - downloadPath, err := DownloadReleaseAsset(ctx, opts.Client, assetURL, targetPath, auth, source) + downloadPath, err := DownloadReleaseAsset(ctx, opts.Client, assetURL, targetPath, auth, source, opts.MaxDownloadBytes) if err != nil { return err } @@ -394,7 +399,18 @@ func (r Release) AssetURL(assetName, releaseURL string) (string, error) { ) } -func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, targetPath string, auth Auth, source ReleaseSource) (string, error) { +func DownloadReleaseAsset( + ctx context.Context, + client *http.Client, + assetURL, targetPath string, + auth Auth, + source ReleaseSource, + maxDownloadBytes int64, +) (string, error) { + if maxDownloadBytes <= 0 { + maxDownloadBytes = defaultMaxDownloadBytes + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) if err != nil { return "", fmt.Errorf("build artifact download request: %w", err) @@ -419,6 +435,13 @@ func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, ta strings.TrimSpace(string(body)), ) } + if resp.ContentLength > 0 && resp.ContentLength > maxDownloadBytes { + return "", fmt.Errorf( + "download release artifact: content length %d exceeds limit %d bytes", + resp.ContentLength, + maxDownloadBytes, + ) + } existingInfo, err := os.Stat(targetPath) if err != nil { @@ -437,9 +460,14 @@ func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, ta return "", copyErr } - if _, err := io.Copy(tempFile, resp.Body); err != nil { + limited := &io.LimitedReader{R: resp.Body, N: maxDownloadBytes + 1} + written, err := io.Copy(tempFile, limited) + if err != nil { return cleanup(fmt.Errorf("write downloaded artifact: %w", err)) } + if written > maxDownloadBytes { + return cleanup(fmt.Errorf("write downloaded artifact: size exceeds limit %d bytes", maxDownloadBytes)) + } if err := tempFile.Chmod(existingInfo.Mode().Perm()); err != nil { return cleanup(fmt.Errorf("set executable mode on downloaded artifact: %w", err)) } diff --git a/update/update_test.go b/update/update_test.go index ab427e3..9c3f98d 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -384,6 +384,45 @@ func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) return fn(req) } +func TestDownloadReleaseAssetRejectsArtifactOverLimit(t *testing.T) { + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.String() != "https://releases.example.com/artifact" { + t.Fatalf("unexpected url: %s", r.URL.String()) + } + body := "123456" + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + ContentLength: int64(len(body)), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + _, err := DownloadReleaseAsset( + context.Background(), + client, + "https://releases.example.com/artifact", + target, + Auth{}, + ReleaseSource{}, + 5, + ) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "exceeds limit") { + t.Fatalf("error = %v", err) + } +} + func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("self-replace is not supported on windows") -- 2.45.2 From fbff660bcc8a3a9c34539e0ab6f6b166b1f22de6 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 12:20:06 +0200 Subject: [PATCH 27/79] =?UTF-8?q?feat:=20ajouter=20la=20v=C3=A9rification?= =?UTF-8?q?=20Ed25519=20des=20artefacts=20de=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +- cmd/mcp-framework/main.go | 5 +- manifest/manifest.go | 55 +++++---- manifest/manifest_test.go | 16 +++ scaffold/scaffold.go | 13 ++ scaffold/scaffold_test.go | 2 + update/update.go | 234 ++++++++++++++++++++++++++++++++++-- update/update_test.go | 247 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 545 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index dad269e..663088e 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,10 @@ repository = "org/repo" base_url = "https://gitea.example.com" asset_name_template = "{binary}-{os}-{arch}{ext}" checksum_asset_name = "{asset}.sha256" -checksum_required = false +checksum_required = true +signature_asset_name = "{asset}.sig" +signature_required = false +signature_public_key_env_names = ["MY_MCP_RELEASE_ED25519_PUBLIC_KEY"] token_header = "Authorization" token_prefix = "token" token_env_names = ["GITEA_TOKEN"] @@ -159,6 +162,10 @@ Champs supportés : - `asset_name_template` : template de nom d'asset (`{binary}`, `{os}`, `{arch}`, `{ext}`). - `checksum_asset_name` : nom d'asset checksum, avec placeholder optionnel `{asset}`. - `checksum_required` : si `true`, l'update échoue quand l'asset checksum est absent. +- `signature_asset_name` : nom d'asset signature Ed25519 (détachée), avec placeholder optionnel `{asset}`. +- `signature_required` : si `true`, l'update échoue si la signature ou la clé publique manquent, ou si la signature est invalide. +- `signature_public_key` : clé publique Ed25519 (hex ou base64) utilisée pour vérifier la signature. +- `signature_public_key_env_names` : variables d'environnement candidates contenant la clé publique Ed25519. - `token_header` : header HTTP à utiliser pour l'authentification. - `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...). - `token_env_names` : liste de variables d'environnement candidates pour retrouver le token. diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go index d6f98b9..a4a263f 100644 --- a/cmd/mcp-framework/main.go +++ b/cmd/mcp-framework/main.go @@ -77,6 +77,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error { var releaseBaseURL string var releaseRepository string var releaseTokenEnv string + var releasePublicKeyEnv string var overwrite bool fs.StringVar(&target, "target", "", "Répertoire cible du nouveau projet (requis)") @@ -92,6 +93,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error { fs.StringVar(&releaseBaseURL, "release-base-url", "", "Base URL de la forge release") fs.StringVar(&releaseRepository, "release-repository", "", "Repository release (org/repo)") fs.StringVar(&releaseTokenEnv, "release-token-env", "", "Nom de variable d'environnement pour le token release") + fs.StringVar(&releasePublicKeyEnv, "release-pubkey-env", "", "Nom de variable d'environnement pour la cle publique Ed25519 de signature") fs.BoolVar(&overwrite, "overwrite", false, "Autorise l'écrasement des fichiers existants") if err := fs.Parse(args); err != nil { @@ -121,6 +123,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error { ReleaseBaseURL: releaseBaseURL, ReleaseRepository: releaseRepository, ReleaseTokenEnv: releaseTokenEnv, + ReleasePublicKeyEnv: releasePublicKeyEnv, Overwrite: overwrite, }) if err != nil { @@ -159,7 +162,7 @@ func printScaffoldHelp(w io.Writer) { func printScaffoldInitHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s scaffold init --target [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --overwrite Écraser les fichiers existants\n", + "Usage:\n %s scaffold init --target [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --release-pubkey-env Variable cle publique Ed25519 release\n --overwrite Écraser les fichiers existants\n", toolName, ) } diff --git a/manifest/manifest.go b/manifest/manifest.go index b3c9414..544e59a 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -25,17 +25,21 @@ type File struct { } type Update struct { - SourceName string `toml:"source_name"` - Driver string `toml:"driver"` - Repository string `toml:"repository"` - BaseURL string `toml:"base_url"` - LatestReleaseURL string `toml:"latest_release_url"` - AssetNameTemplate string `toml:"asset_name_template"` - ChecksumAssetName string `toml:"checksum_asset_name"` - ChecksumRequired bool `toml:"checksum_required"` - TokenHeader string `toml:"token_header"` - TokenPrefix string `toml:"token_prefix"` - TokenEnvNames []string `toml:"token_env_names"` + SourceName string `toml:"source_name"` + Driver string `toml:"driver"` + Repository string `toml:"repository"` + BaseURL string `toml:"base_url"` + LatestReleaseURL string `toml:"latest_release_url"` + AssetNameTemplate string `toml:"asset_name_template"` + ChecksumAssetName string `toml:"checksum_asset_name"` + ChecksumRequired bool `toml:"checksum_required"` + SignatureAssetName string `toml:"signature_asset_name"` + SignatureRequired bool `toml:"signature_required"` + SignaturePublicKey string `toml:"signature_public_key"` + SignaturePublicKeyEnvNames []string `toml:"signature_public_key_env_names"` + TokenHeader string `toml:"token_header"` + TokenPrefix string `toml:"token_prefix"` + TokenEnvNames []string `toml:"token_env_names"` } type Environment struct { @@ -155,6 +159,9 @@ func (u *Update) normalize() { u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL) u.AssetNameTemplate = strings.TrimSpace(u.AssetNameTemplate) u.ChecksumAssetName = strings.TrimSpace(u.ChecksumAssetName) + u.SignatureAssetName = strings.TrimSpace(u.SignatureAssetName) + u.SignaturePublicKey = strings.TrimSpace(u.SignaturePublicKey) + u.SignaturePublicKeyEnvNames = normalizeStringList(u.SignaturePublicKeyEnvNames) u.TokenHeader = strings.TrimSpace(u.TokenHeader) u.TokenPrefix = strings.TrimSpace(u.TokenPrefix) u.TokenEnvNames = normalizeStringList(u.TokenEnvNames) @@ -181,17 +188,21 @@ func (u Update) ReleaseSource() update.ReleaseSource { u.normalize() return update.ReleaseSource{ - Name: u.SourceName, - Driver: u.Driver, - Repository: u.Repository, - BaseURL: u.BaseURL, - LatestReleaseURL: u.LatestReleaseURL, - AssetNameTemplate: u.AssetNameTemplate, - ChecksumAssetName: u.ChecksumAssetName, - ChecksumRequired: u.ChecksumRequired, - TokenHeader: u.TokenHeader, - TokenPrefix: u.TokenPrefix, - TokenEnvNames: append([]string(nil), u.TokenEnvNames...), + Name: u.SourceName, + Driver: u.Driver, + Repository: u.Repository, + BaseURL: u.BaseURL, + LatestReleaseURL: u.LatestReleaseURL, + AssetNameTemplate: u.AssetNameTemplate, + ChecksumAssetName: u.ChecksumAssetName, + ChecksumRequired: u.ChecksumRequired, + SignatureAssetName: u.SignatureAssetName, + SignatureRequired: u.SignatureRequired, + SignaturePublicKey: u.SignaturePublicKey, + SignaturePublicKeyEnvNames: append([]string(nil), u.SignaturePublicKeyEnvNames...), + TokenHeader: u.TokenHeader, + TokenPrefix: u.TokenPrefix, + TokenEnvNames: append([]string(nil), u.TokenEnvNames...), } } diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 83ea7d5..5d6a739 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -53,6 +53,10 @@ latest_release_url = "https://gitea.example.com/api/releases/latest" asset_name_template = "{binary}_{os}_{arch}{ext}" checksum_asset_name = "{asset}.sha256" checksum_required = true +signature_asset_name = "{asset}.sig" +signature_required = true +signature_public_key = " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " +signature_public_key_env_names = [" MCP_PUBKEY ", "", "MCP_RELEASE_PUBKEY"] token_header = " Authorization " token_prefix = " token " token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"] @@ -92,6 +96,18 @@ token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"] if !source.ChecksumRequired { t.Fatal("checksum required should be true") } + if source.SignatureAssetName != "{asset}.sig" { + t.Fatalf("signature asset name = %q", source.SignatureAssetName) + } + if !source.SignatureRequired { + t.Fatal("signature required should be true") + } + if source.SignaturePublicKey == "" { + t.Fatal("signature public key should be set") + } + if len(source.SignaturePublicKeyEnvNames) != 2 { + t.Fatalf("signature env names = %v", source.SignaturePublicKeyEnvNames) + } if source.TokenHeader != "Authorization" { t.Fatalf("token header = %q", source.TokenHeader) } diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index c91ac45..81e9fe7 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -31,6 +31,7 @@ type Options struct { ReleaseBaseURL string ReleaseRepository string ReleaseTokenEnv string + ReleasePublicKeyEnv string Overwrite bool } @@ -56,6 +57,7 @@ type normalizedOptions struct { ReleaseBaseURL string ReleaseRepository string ReleaseTokenEnv string + ReleasePublicKeyEnv string Overwrite bool } @@ -231,6 +233,13 @@ func normalizeOptions(options Options) (normalizedOptions, error) { if releaseTokenEnv == "" { releaseTokenEnv = envPrefix + "_RELEASE_TOKEN" } + releasePublicKeyEnv := strings.TrimSpace(options.ReleasePublicKeyEnv) + if releasePublicKeyEnv == "" { + releasePublicKeyEnv = envPrefix + "_RELEASE_ED25519_PUBLIC_KEY" + } + if !slices.Contains(knownEnvironmentVariables, releasePublicKeyEnv) { + knownEnvironmentVariables = append(knownEnvironmentVariables, releasePublicKeyEnv) + } return normalizedOptions{ TargetDir: resolvedTarget, @@ -249,6 +258,7 @@ func normalizeOptions(options Options) (normalizedOptions, error) { ReleaseBaseURL: releaseBaseURL, ReleaseRepository: releaseRepository, ReleaseTokenEnv: releaseTokenEnv, + ReleasePublicKeyEnv: releasePublicKeyEnv, Overwrite: options.Overwrite, }, nil } @@ -1182,6 +1192,9 @@ base_url = "{{.ReleaseBaseURL}}" asset_name_template = "{binary}-{os}-{arch}{ext}" checksum_asset_name = "{asset}.sha256" checksum_required = true +signature_asset_name = "{asset}.sig" +signature_required = false +signature_public_key_env_names = ["{{.ReleasePublicKeyEnv}}"] token_header = "Authorization" token_prefix = "token" token_env_names = ["{{.ReleaseTokenEnv}}"] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index bf72730..be726db 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -89,6 +89,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "binary_name = \"my-mcp\"", "[update]", "checksum_required = true", + "signature_asset_name = \"{asset}.sig\"", + "signature_required = false", "[secret_store]", "[environment]", "[profiles]", diff --git a/update/update.go b/update/update.go index 6c5dbfb..21aa789 100644 --- a/update/update.go +++ b/update/update.go @@ -2,7 +2,9 @@ package update import ( "context" + "crypto/ed25519" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -52,18 +54,22 @@ type ValidationInput struct { } type ReleaseSource struct { - Name string - Driver string - Repository string - BaseURL string - LatestReleaseURL string - AssetNameTemplate string - ChecksumAssetName string - ChecksumRequired bool - Token string - TokenHeader string - TokenPrefix string - TokenEnvNames []string + Name string + Driver string + Repository string + BaseURL string + LatestReleaseURL string + AssetNameTemplate string + ChecksumAssetName string + ChecksumRequired bool + SignatureAssetName string + SignatureRequired bool + SignaturePublicKey string + SignaturePublicKeyEnvNames []string + Token string + TokenHeader string + TokenPrefix string + TokenEnvNames []string } type Auth struct { @@ -176,6 +182,9 @@ func Run(ctx context.Context, opts Options) error { if err := VerifyReleaseAssetChecksum(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil { return err } + if err := VerifyReleaseAssetSignature(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil { + return err + } if opts.ValidateDownloaded != nil { if err := opts.ValidateDownloaded(ctx, ValidationInput{ @@ -527,6 +536,70 @@ func VerifyReleaseAssetChecksum( return nil } +func VerifyReleaseAssetSignature( + ctx context.Context, + client *http.Client, + release Release, + releaseURL string, + assetName string, + artifactPath string, + auth Auth, + source ReleaseSource, +) error { + source = normalizeSource(source) + + publicKey, hasPublicKey, err := resolveEd25519PublicKey(source.SignaturePublicKey, source.SignaturePublicKeyEnvNames) + if err != nil { + return fmt.Errorf("signature verification: %w", err) + } + if !hasPublicKey { + if source.SignatureRequired { + if len(source.SignaturePublicKeyEnvNames) > 0 { + return fmt.Errorf( + "signature verification: no Ed25519 public key configured (set %s)", + strings.Join(source.SignaturePublicKeyEnvNames, " or "), + ) + } + return errors.New("signature verification: no Ed25519 public key configured") + } + return nil + } + + signatureAssetName := resolveSignatureAssetName(assetName, source.SignatureAssetName) + signatureURL, err := release.AssetURL(signatureAssetName, releaseURL) + if err != nil { + if source.SignatureRequired { + return fmt.Errorf("signature verification: %w", err) + } + return nil + } + + signatureBody, err := downloadAssetBytes(ctx, client, signatureURL, auth, source) + if err != nil { + return fmt.Errorf("signature verification: %w", err) + } + + signature, err := parseEd25519Signature(string(signatureBody), assetName) + if err != nil { + return fmt.Errorf("signature verification: %w", err) + } + + digestHex, err := fileSHA256(artifactPath) + if err != nil { + return fmt.Errorf("signature verification: %w", err) + } + digest, err := hex.DecodeString(digestHex) + if err != nil { + return fmt.Errorf("signature verification: decode local artifact digest: %w", err) + } + + if !ed25519.Verify(publicKey, digest, signature) { + return fmt.Errorf("signature mismatch for asset %q", assetName) + } + + return nil +} + func ReplaceExecutable(downloadPath, targetPath string) error { if runtime.GOOS == "windows" { return errors.New("self-update is not supported on windows without a custom ReplaceExecutable hook") @@ -545,6 +618,8 @@ func normalizeSource(source ReleaseSource) ReleaseSource { source.LatestReleaseURL = strings.TrimSpace(source.LatestReleaseURL) source.AssetNameTemplate = strings.TrimSpace(source.AssetNameTemplate) source.ChecksumAssetName = strings.TrimSpace(source.ChecksumAssetName) + source.SignatureAssetName = strings.TrimSpace(source.SignatureAssetName) + source.SignaturePublicKey = strings.TrimSpace(source.SignaturePublicKey) source.Token = strings.TrimSpace(source.Token) source.TokenHeader = strings.TrimSpace(source.TokenHeader) source.TokenPrefix = strings.TrimSpace(source.TokenPrefix) @@ -557,6 +632,14 @@ func normalizeSource(source ReleaseSource) ReleaseSource { } source.TokenEnvNames = envNames + publicKeyEnvNames := source.SignaturePublicKeyEnvNames[:0] + for _, envName := range source.SignaturePublicKeyEnvNames { + if trimmed := strings.TrimSpace(envName); trimmed != "" { + publicKeyEnvNames = append(publicKeyEnvNames, trimmed) + } + } + source.SignaturePublicKeyEnvNames = publicKeyEnvNames + switch source.Driver { case "gitea": if source.Name == "" { @@ -742,6 +825,14 @@ func resolveChecksumAssetName(assetName, configured string) string { return strings.ReplaceAll(value, "{asset}", assetName) } +func resolveSignatureAssetName(assetName, configured string) string { + value := strings.TrimSpace(configured) + if value == "" { + return assetName + ".sig" + } + return strings.ReplaceAll(value, "{asset}", assetName) +} + func downloadAssetBytes(ctx context.Context, client *http.Client, assetURL string, auth Auth, source ReleaseSource) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) if err != nil { @@ -835,6 +926,125 @@ func parseChecksum(content, assetName string) (string, error) { return "", fmt.Errorf("checksum file does not contain a sha256 for asset %q", assetName) } +func parseEd25519Signature(content, assetName string) ([]byte, error) { + lines := strings.Split(content, "\n") + var fallbackSingle []byte + + for _, raw := range lines { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + fields := strings.Fields(line) + if len(fields) > 0 { + if signature, ok := parseEd25519SignatureToken(fields[0]); ok { + if len(fields) == 1 { + if fallbackSingle == nil { + fallbackSingle = signature + } + continue + } + + name := strings.TrimSpace(strings.TrimPrefix(fields[1], "*")) + if matchesAssetName(name, assetName) { + return signature, nil + } + } + } + + colonIndex := strings.Index(line, ":") + if colonIndex > 0 && colonIndex < len(line)-1 { + left := strings.TrimSpace(line[:colonIndex]) + right := strings.TrimSpace(line[colonIndex+1:]) + + if signature, ok := parseEd25519SignatureToken(left); ok && matchesAssetName(right, assetName) { + return signature, nil + } + if signature, ok := parseEd25519SignatureToken(right); ok && matchesAssetName(left, assetName) { + return signature, nil + } + } + } + + if fallbackSingle != nil { + return fallbackSingle, nil + } + + return nil, fmt.Errorf("signature file does not contain a valid Ed25519 signature for asset %q", assetName) +} + +func parseEd25519SignatureToken(value string) ([]byte, bool) { + decoded, err := decodeBinaryValue(value, ed25519.SignatureSize) + if err != nil { + return nil, false + } + return decoded, true +} + +func resolveEd25519PublicKey(explicit string, envNames []string) (ed25519.PublicKey, bool, error) { + key := strings.TrimSpace(explicit) + if key != "" { + publicKey, err := parseEd25519PublicKey(key) + if err != nil { + return nil, false, fmt.Errorf("parse ed25519 public key: %w", err) + } + return publicKey, true, nil + } + + for _, envName := range envNames { + if value := strings.TrimSpace(os.Getenv(envName)); value != "" { + publicKey, err := parseEd25519PublicKey(value) + if err != nil { + return nil, false, fmt.Errorf("parse ed25519 public key from %s: %w", envName, err) + } + return publicKey, true, nil + } + } + + return nil, false, nil +} + +func parseEd25519PublicKey(value string) (ed25519.PublicKey, error) { + decoded, err := decodeBinaryValue(value, ed25519.PublicKeySize) + if err != nil { + return nil, err + } + return ed25519.PublicKey(decoded), nil +} + +func decodeBinaryValue(value string, expectedLength int) ([]byte, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, errors.New("value must not be empty") + } + + decoders := []func(string) ([]byte, error){ + hex.DecodeString, + base64.StdEncoding.DecodeString, + base64.RawStdEncoding.DecodeString, + base64.URLEncoding.DecodeString, + base64.RawURLEncoding.DecodeString, + } + + lengthMismatch := false + for _, decode := range decoders { + decoded, err := decode(trimmed) + if err != nil { + continue + } + if len(decoded) == expectedLength { + return decoded, nil + } + lengthMismatch = true + } + + if lengthMismatch { + return nil, fmt.Errorf("decoded value has invalid length (expected %d bytes)", expectedLength) + } + return nil, errors.New("value must be hex or base64 encoded") +} + func matchesAssetName(candidate, assetName string) bool { name := strings.TrimSpace(strings.TrimPrefix(candidate, "*")) name = strings.TrimPrefix(name, "./") diff --git a/update/update_test.go b/update/update_test.go index 9c3f98d..dcc0c81 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -3,6 +3,8 @@ package update import ( "bytes" "context" + "crypto/ed25519" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" @@ -674,6 +676,251 @@ func TestRunVerifiesChecksumWhenSidecarAvailable(t *testing.T) { } } +func TestRunVerifiesEd25519SignatureWhenConfigured(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + const newBinary = "new-binary" + digest := sha256.Sum256([]byte(newBinary)) + signature := ed25519.Sign(privateKey, digest[:]) + signatureBody := hex.EncodeToString(signature) + " " + assetName + "\n" + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + {Name: assetName + ".sig", URL: "https://releases.example.com/artifact.sig"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(newBinary)), + }, nil + case "https://releases.example.com/artifact.sig": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(signatureBody)), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + t.Setenv("RELEASE_PUBKEY", hex.EncodeToString(publicKey)) + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReleaseSource: ReleaseSource{ + SignatureRequired: true, + SignaturePublicKeyEnvNames: []string{"RELEASE_PUBKEY"}, + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestRunFailsOnEd25519SignatureMismatch(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + signature := ed25519.Sign(privateKey, []byte("wrong-digest")) + signatureBody := hex.EncodeToString(signature) + " " + assetName + "\n" + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + {Name: assetName + ".sig", URL: "https://releases.example.com/artifact.sig"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + case "https://releases.example.com/artifact.sig": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(signatureBody)), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + t.Setenv("RELEASE_PUBKEY", hex.EncodeToString(publicKey)) + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReleaseSource: ReleaseSource{ + SignatureRequired: true, + SignaturePublicKeyEnvNames: []string{"RELEASE_PUBKEY"}, + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "signature mismatch") { + t.Fatalf("error = %v", err) + } +} + +func TestRunFailsWhenSignatureRequiredAndPublicKeyMissing(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + } + payload, err := json.Marshal(release) + if err != nil { + t.Fatalf("Marshal release: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("new-binary")), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReleaseSource: ReleaseSource{ + SignatureRequired: true, + }, + ReplaceExecutable: func(downloadPath, targetPath string) error { + data, readErr := os.ReadFile(downloadPath) + if readErr != nil { + return readErr + } + return os.WriteFile(targetPath, data, 0o755) + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(strings.ToLower(err.Error()), "public key") { + t.Fatalf("error = %v", err) + } +} + func TestRunFailsOnChecksumMismatch(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { -- 2.45.2 From f0e2e9304b21e82bbd26419bc7125e7e6c5e6251 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 14:06:28 +0200 Subject: [PATCH 28/79] docs: reorganize README and split detailed documentation --- README.md | 654 ++-------------------------------------- docs/README.md | 17 ++ docs/auto-update.md | 51 ++++ docs/bootstrap-cli.md | 45 +++ docs/cli-helpers.md | 187 ++++++++++++ docs/config.md | 64 ++++ docs/getting-started.md | 40 +++ docs/limitations.md | 3 + docs/manifest.md | 83 +++++ docs/minimal-example.md | 36 +++ docs/packages.md | 9 + docs/scaffolding.md | 26 ++ docs/secrets.md | 89 ++++++ 13 files changed, 674 insertions(+), 630 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/auto-update.md create mode 100644 docs/bootstrap-cli.md create mode 100644 docs/cli-helpers.md create mode 100644 docs/config.md create mode 100644 docs/getting-started.md create mode 100644 docs/limitations.md create mode 100644 docs/manifest.md create mode 100644 docs/minimal-example.md create mode 100644 docs/packages.md create mode 100644 docs/scaffolding.md create mode 100644 docs/secrets.md diff --git a/README.md b/README.md index 663088e..e9b682d 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,23 @@ # mcp-framework -Bibliothèque Go pour construire des binaires MCP avec : +`mcp-framework` est une bibliothèque Go pour construire des binaires MCP robustes, sans imposer un runtime lourd. -- résolution de profils CLI -- stockage JSON de configuration dans `os.UserConfigDir()` -- stockage de secrets dans le wallet natif selon l'OS -- lecture d'un manifeste `mcp.toml` à la racine du projet -- pipeline d'auto-update via endpoint de release configurable +## Le principal à savoir -Le framework est volontairement petit. Il fournit des briques réutilisables, -pas une application MCP complète. +- Le framework fournit des briques réutilisables : config locale, secrets, résolution CLI, manifeste projet, et auto-update. +- Il peut être utilisé de manière modulaire (package par package) ou avec un bootstrap CLI prêt à l'emploi. +- Il inclut un générateur de squelette (`mcp-framework scaffold init`) pour démarrer un nouveau binaire MCP rapidement. +- Toute la documentation détaillée est maintenant organisée dans `docs/` par grandes parties. -## Installation +## Démarrage rapide + +Installer le framework dans un projet Go existant : ```bash go get gitea.lclr.dev/AI/mcp-framework ``` -## CLI de scaffold - -Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go : +Initialiser un nouveau projet MCP depuis un dossier vide : ```bash go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest @@ -38,621 +36,17 @@ go mod tidy go run ./cmd/my-mcp help ``` -## Packages - -- `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, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. -- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage). -- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`. -- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. - -## Utilisation type - -Le flux typique côté application est : - -1. Déclarer les sous-commandes communes via `bootstrap` (optionnel). -2. Résoudre le profil actif avec `cli`. -3. Charger la config versionnée avec `config`. -4. Lire les secrets avec `secretstore`. -5. Charger `mcp.toml` avec `manifest`. -6. Exécuter l'auto-update avec `update` si nécessaire. -7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. - -## Bootstrap CLI - -Le package `bootstrap` reste optionnel : une application peut l'utiliser pour -uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique. - -Exemple minimal : - -```go -func main() { - err := bootstrap.Run(context.Background(), bootstrap.Options{ - BinaryName: "my-mcp", - Description: "Client MCP", - Version: version, - EnableDoctorAlias: true, // expose `doctor` comme alias de `config test` - AliasDescriptions: map[string]string{ - "doctor": "Diagnostiquer la configuration locale.", - }, - Hooks: bootstrap.Hooks{ - Setup: func(ctx context.Context, inv bootstrap.Invocation) error { - return runSetup(ctx, inv.Args) - }, - MCP: func(ctx context.Context, inv bootstrap.Invocation) error { - return runMCP(ctx, inv.Args) - }, - ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error { - return runConfigShow(ctx, inv.Args) - }, - ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error { - return runConfigTest(ctx, inv.Args) - }, - Update: func(ctx context.Context, inv bootstrap.Invocation) error { - return runUpdate(ctx, inv.Args) - }, - }, - }) - if err != nil { - log.Fatal(err) - } -} -``` - -Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche -automatiquement `Options.Version`. - -Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). -La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). -Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. - -## Manifeste `mcp.toml` - -Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire -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" -driver = "gitea" -repository = "org/repo" -base_url = "https://gitea.example.com" -asset_name_template = "{binary}-{os}-{arch}{ext}" -checksum_asset_name = "{asset}.sha256" -checksum_required = true -signature_asset_name = "{asset}.sig" -signature_required = false -signature_public_key_env_names = ["MY_MCP_RELEASE_ED25519_PUBLIC_KEY"] -token_header = "Authorization" -token_prefix = "token" -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 : - -- `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. -- `driver` : driver de forge (`gitea`, `gitlab`, `github`) pour déduire automatiquement l'endpoint latest. -- `repository` : dépôt cible (`org/repo` ou `group/subgroup/repo`). -- `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 (prioritaire sur le driver). -- `asset_name_template` : template de nom d'asset (`{binary}`, `{os}`, `{arch}`, `{ext}`). -- `checksum_asset_name` : nom d'asset checksum, avec placeholder optionnel `{asset}`. -- `checksum_required` : si `true`, l'update échoue quand l'asset checksum est absent. -- `signature_asset_name` : nom d'asset signature Ed25519 (détachée), avec placeholder optionnel `{asset}`. -- `signature_required` : si `true`, l'update échoue si la signature ou la clé publique manquent, ou si la signature est invalide. -- `signature_public_key` : clé publique Ed25519 (hex ou base64) utilisée pour vérifier la signature. -- `signature_public_key_env_names` : variables d'environnement candidates contenant la clé publique Ed25519. -- `token_header` : header HTTP à utiliser pour l'authentification. -- `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...). -- `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 : - -```go -file, path, err := manifest.LoadDefault(".") -if err != nil { - return err -} - -fmt.Printf("manifest loaded from %s\n", path) -source := file.Update.ReleaseSource() -bootstrapInfo := file.BootstrapInfo() -scaffoldInfo := file.ScaffoldInfo() -_ = bootstrapInfo -_ = scaffoldInfo -``` - -## Scaffolding - -Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : - -- arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) -- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP) -- wiring initial `bootstrap + config + secretstore + update` -- `README.md` de démarrage - -Exemple : - -```go -result, err := scaffold.Generate(scaffold.Options{ - TargetDir: "./my-mcp", - ModulePath: "gitea.lclr.dev/AI/my-mcp", - BinaryName: "my-mcp", - Description: "Client MCP interne", - DefaultProfile: "prod", - Profiles: []string{"dev", "prod"}, -}) -if err != nil { - return err -} - -fmt.Printf("Scaffold generated in %s (%d files)\n", result.Root, len(result.Files)) -``` - -## Config JSON - -Le package `config` stocke une structure générique par profil dans un JSON privé -pour l'utilisateur courant. - -Exemple : - -```go -type Profile struct { - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` -} - -store := config.NewStore[Profile]("my-mcp") - -cfg, path, err := store.LoadDefault() -if err != nil { - return err -} - -profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) -profile := cfg.Profiles[profileName] - -profile.BaseURL = "https://api.example.com" -cfg.CurrentProfile = profileName -cfg.Profiles[profileName] = profile - -_, err = store.SaveDefault(cfg) -if err != nil { - return err -} - -fmt.Printf("config saved to %s\n", path) -``` - -Notes : - -- le fichier est créé avec des permissions `0600` -- le répertoire parent est forcé en `0700` -- l'écriture est atomique via un fichier temporaire puis `rename` -- si le fichier n'existe pas, `Load` et `LoadDefault` retournent une config vide par défaut -- `NewStoreWithOptions` permet de définir une version cible, des migrations JSON (`from -> to`) et une validation explicite après chargement -- une config plus récente que la version supportée, ou sans chemin de migration complet, retourne une erreur explicite - -Exemple de store versionné : - -```go -store := config.NewStoreWithOptions[Profile]("my-mcp", config.Options[Profile]{ - Version: 2, - Migrations: map[int]config.Migration{ - 1: func(doc map[string]json.RawMessage) error { - return nil - }, - }, - Validator: func(cfg config.FileConfig[Profile]) []config.ValidationIssue { - if cfg.CurrentProfile == "" { - return []config.ValidationIssue{{ - Path: "current_profile", - Message: "must not be empty", - }} - } - return nil - }, -}) -``` - -## Secrets - -Le package `secretstore` supporte plusieurs politiques de backend : - -- `auto` : comportement par défaut, utilise un backend keyring disponible et peut retomber sur l'environnement si `LookupEnv` est fourni -- `kwallet-only` : impose KWallet et retourne une erreur explicite si KWallet n'est pas disponible -- `keyring-any` : impose l'utilisation d'un backend keyring disponible -- `env-only` : lecture seule depuis les variables d'environnement - -Backends keyring typiques : - -- macOS : Keychain -- Linux : Secret Service ou KWallet selon l'environnement -- Windows : Credential Manager - -Ouverture recommandée depuis la policy du manifeste runtime (`mcp.toml` résolu -près de l'exécutable) : - -```go -store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ - ServiceName: "my-mcp", - LookupEnv: os.LookupEnv, -}) -if err != nil { - return err -} -``` - -Exemple bas niveau : - -```go -store, err := secretstore.Open(secretstore.Options{ - ServiceName: "my-mcp", - BackendPolicy: secretstore.BackendAuto, -}) -if err != nil { - return err -} - -if err := store.SetSecret("api-token", "My MCP API token", token); err != nil { - return err -} - -token, err = store.GetSecret("api-token") -switch { -case err == nil: - // secret found -case errors.Is(err, secretstore.ErrNotFound): - // first run -default: - return err -} -``` - -Pour imposer KWallet sur Linux : - -```go -store, err := secretstore.Open(secretstore.Options{ - ServiceName: "email-mcp", - BackendPolicy: secretstore.BackendKWalletOnly, -}) -``` - -Pour stocker un secret structuré en JSON : - -```go -type Credentials struct { - Host string `json:"host"` - Username string `json:"username"` - Password string `json:"password"` -} - -err = secretstore.SetJSON(store, "imap-credentials", "IMAP credentials", Credentials{ - Host: "imap.example.com", - Username: "alice", - Password: token, -}) -if err != nil { - return err -} - -creds, err := secretstore.GetJSON[Credentials](store, "imap-credentials") -if err != nil { - return err -} -``` - -En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`. -Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`. - -## Helpers CLI - -`cli` fournit des helpers simples pour les assistants interactifs : - -```go -profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) - -baseURL, err := cli.PromptLine(reader, os.Stdout, "Base URL", profile.BaseURL) -if err != nil { - return err -} -if err := cli.ValidateBaseURL(baseURL); err != nil { - return err -} - -token, err := cli.PromptSecret(os.Stdin, os.Stdout, "API token", hasStoredSecret, storedToken) -if err != nil { - return err -} -``` - -Pour décrire un setup complet sans réécrire la boucle interactive : - -```go -result, err := cli.RunSetup(cli.SetupOptions{ - Stdin: os.Stdin, - Stdout: os.Stdout, - Fields: []cli.SetupField{ - { - Name: "base_url", - Label: "Base URL", - Type: cli.SetupFieldURL, - Required: true, - }, - { - Name: "api_token", - Label: "API token", - Type: cli.SetupFieldSecret, - Required: true, - ExistingSecret: storedToken, // conserve la valeur existante si l'utilisateur laisse vide - }, - { - Name: "enabled", - Label: "Enable integration", - Type: cli.SetupFieldBool, - Default: "true", - }, - { - Name: "scopes", - Label: "Scopes", - Type: cli.SetupFieldList, - Default: "read,write", - Normalize: func(value string) string { return strings.TrimSpace(strings.ToLower(value)) }, - }, - }, -}) -if err != nil { - return err -} - -baseURL, _ := result.Get("base_url") -apiToken, _ := result.Get("api_token") -enabled, _ := result.Get("enabled") -scopes, _ := result.Get("scopes") - -if apiToken.KeptStoredSecret { - fmt.Println("Stored token kept.") -} -``` - -Chaque champ peut déclarer ses propres hooks de validation (`Validate`, `ValidateBool`, `ValidateList`). -Les validations sont appliquées de manière cohérente en TTY et en stdin non interactif. - -Pour standardiser la résolution `flag > env > config > secret` avec provenance : - -```go -store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ - ServiceName: "my-mcp", -}) -if err != nil { - return err -} - -lookup := cli.ResolveLookup(cli.ResolveLookupOptions{ - Flag: cli.MapLookup(flagValues), - Env: cli.EnvLookup(os.LookupEnv), - Config: cli.ConfigMap(configValues), - Secret: cli.SecretStore(store), // ErrNotFound => valeur absente, pas une erreur -}) - -resolution, err := cli.ResolveFields(cli.ResolveOptions{ - Fields: []cli.FieldSpec{ - {Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"}, - {Name: "api_token", Required: true, EnvKey: "MY_MCP_API_TOKEN", SecretKey: "my-mcp-api-token"}, - {Name: "timeout", DefaultValue: "30s"}, - }, - Lookup: lookup, -}) -if err != nil { - return err -} - -if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil { - return err -} -``` - -`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et -`FieldSpec.Sources` permet de définir un ordre spécifique pour un champ. - -Le package fournit aussi un socle réutilisable pour une commande `doctor`. -L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), -mais peut réutiliser les checks communs et ajouter ses propres hooks : - -```go -report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ - ConfigCheck: cli.NewConfigCheck(store), - SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) { - return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ - ServiceName: "my-mcp", - }) - }), - RequiredSecrets: []cli.DoctorSecret{ - {Name: "api-token", Label: "API token"}, - }, - SecretStoreFactory: func() (secretstore.Store, error) { - return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ - ServiceName: "my-mcp", - }) - }, - ManifestDir: ".", - ConnectivityCheck: func(context.Context) cli.DoctorResult { - if err := pingBackend(); err != nil { - return cli.DoctorResult{ - Name: "connectivity", - Status: cli.DoctorStatusFail, - Summary: "backend is unreachable", - Detail: err.Error(), - } - } - return cli.DoctorResult{ - Name: "connectivity", - Status: cli.DoctorStatusOK, - Summary: "backend is reachable", - } - }, -}) - -if err := cli.RenderDoctorReport(os.Stdout, report); err != nil { - return err -} - -if report.HasFailures() { - os.Exit(1) -} -``` - -Pour éviter des checks custom répétitifs sur les profils, `doctor` expose aussi -un helper basé sur `FieldSpec` : - -```go -report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ - ExtraChecks: []cli.DoctorCheck{ - cli.RequiredResolvedFieldsCheck(cli.ResolveOptions{ - Fields: []cli.FieldSpec{ - {Name: "base_url", Required: true, EnvKey: "MY_MCP_BASE_URL"}, - { - Name: "api_token", - Required: true, - EnvKey: "MY_MCP_API_TOKEN", - SecretKey: "my-mcp-api-token", - Sources: []cli.ValueSource{cli.SourceEnv, cli.SourceSecret}, - }, - }, - Lookup: cli.ResolveLookup(cli.ResolveLookupOptions{ - Env: cli.EnvLookup(os.LookupEnv), - Config: cli.ConfigMap(configValues), - Secret: cli.SecretStore(secretStore), // provenance explicite: env ou secret - }), - }), - }, -}) -``` - -En cas d'échec de résolution, tu peux aussi réutiliser le formatteur -`cli.FormatResolveFieldsError(err)` dans un check custom pour garder des messages homogènes. - -## Auto-Update - -Le package `update` supporte les drivers `gitea`, `gitlab` et `github`. -Si `latest_release_url` est vide, l'URL latest est déduite depuis -`driver + repository (+ base_url)`. - -Le parseur de release supporte : - -- format `assets.links` (Gitea/GitLab) -- format `assets[]` avec `browser_download_url` (GitHub et Gitea API) - -Le format attendu pour la réponse `latest release` est actuellement : - -```json -{ - "tag_name": "v1.2.3", - "assets": { - "links": [ - { - "name": "my-mcp-linux-amd64", - "url": "https://example.com/downloads/my-mcp-linux-amd64" - } - ] - } -} -``` - -Exemple : - -```go -file, _, err := manifest.LoadDefault(".") -if err != nil { - return err -} - -err = update.Run(ctx, update.Options{ - CurrentVersion: version, - BinaryName: "my-mcp", - ReleaseSource: file.Update.ReleaseSource(), - Stdout: os.Stdout, -}) -if err != nil { - return err -} -``` - -Comportement : - -- le nom de l'asset est configurable (`asset_name_template`) et supporte tout couple `GOOS/GOARCH` -- si un asset `.sha256` (ou `checksum_asset_name`) existe, le binaire téléchargé est vérifié avant remplacement -- un hook `ValidateDownloaded` permet d'ajouter une validation custom (signature, scan, etc.) -- sur Windows, le remplacement in-place n'est pas fait par défaut ; fournir `Options.ReplaceExecutable` pour une stratégie dédiée - -## Exemple Minimal - -```go -type Profile struct { - BaseURL string `json:"base_url"` -} - -func run(ctx context.Context, flagProfile string) error { - cfgStore := config.NewStore[Profile]("my-mcp") - - cfg, _, err := cfgStore.LoadDefault() - if err != nil { - return err - } - - profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) - profile := cfg.Profiles[profileName] - - manifestFile, _, err := manifest.LoadDefault(".") - if err != nil { - return err - } - - err = update.Run(ctx, update.Options{ - CurrentVersion: version, - BinaryName: "my-mcp", - ReleaseSource: manifestFile.Update.ReleaseSource(), - }) - if err != nil { - return err - } - - _ = profile - return nil -} -``` - -## Limites Actuelles - -- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes +## Documentation + +- Vue d'ensemble : [docs/README.md](docs/README.md) +- Installation et usage type : [docs/getting-started.md](docs/getting-started.md) +- Packages : [docs/packages.md](docs/packages.md) +- Bootstrap CLI : [docs/bootstrap-cli.md](docs/bootstrap-cli.md) +- Manifeste `mcp.toml` : [docs/manifest.md](docs/manifest.md) +- Scaffolding : [docs/scaffolding.md](docs/scaffolding.md) +- Config JSON : [docs/config.md](docs/config.md) +- Secrets : [docs/secrets.md](docs/secrets.md) +- Helpers CLI : [docs/cli-helpers.md](docs/cli-helpers.md) +- Auto-update : [docs/auto-update.md](docs/auto-update.md) +- Exemple minimal : [docs/minimal-example.md](docs/minimal-example.md) +- Limites actuelles : [docs/limitations.md](docs/limitations.md) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..83fedb6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# Documentation mcp-framework + +Cette documentation est organisée par grandes parties pour séparer la vue d'ensemble des détails d'implémentation. + +## Navigation + +- [Installation et utilisation type](getting-started.md) +- [Packages](packages.md) +- [Bootstrap CLI](bootstrap-cli.md) +- [Manifeste `mcp.toml`](manifest.md) +- [Scaffolding](scaffolding.md) +- [Config JSON](config.md) +- [Secrets](secrets.md) +- [Helpers CLI](cli-helpers.md) +- [Auto-update](auto-update.md) +- [Exemple minimal](minimal-example.md) +- [Limites actuelles](limitations.md) diff --git a/docs/auto-update.md b/docs/auto-update.md new file mode 100644 index 0000000..afce8e1 --- /dev/null +++ b/docs/auto-update.md @@ -0,0 +1,51 @@ +# Auto-update + +Le package `update` supporte les drivers `gitea`, `gitlab` et `github`. +Si `latest_release_url` est vide, l'URL latest est déduite depuis `driver + repository (+ base_url)`. + +Le parseur de release supporte : + +- format `assets.links` (Gitea/GitLab) +- format `assets[]` avec `browser_download_url` (GitHub et Gitea API) + +Le format attendu pour la réponse `latest release` est actuellement : + +```json +{ + "tag_name": "v1.2.3", + "assets": { + "links": [ + { + "name": "my-mcp-linux-amd64", + "url": "https://example.com/downloads/my-mcp-linux-amd64" + } + ] + } +} +``` + +Exemple : + +```go +file, _, err := manifest.LoadDefault(".") +if err != nil { + return err +} + +err = update.Run(ctx, update.Options{ + CurrentVersion: version, + BinaryName: "my-mcp", + ReleaseSource: file.Update.ReleaseSource(), + Stdout: os.Stdout, +}) +if err != nil { + return err +} +``` + +Comportement : + +- le nom de l'asset est configurable (`asset_name_template`) et supporte tout couple `GOOS/GOARCH` +- si un asset `.sha256` (ou `checksum_asset_name`) existe, le binaire téléchargé est vérifié avant remplacement +- un hook `ValidateDownloaded` permet d'ajouter une validation custom (signature, scan, etc.) +- sur Windows, le remplacement in-place n'est pas fait par défaut ; fournir `Options.ReplaceExecutable` pour une stratégie dédiée diff --git a/docs/bootstrap-cli.md b/docs/bootstrap-cli.md new file mode 100644 index 0000000..3bce155 --- /dev/null +++ b/docs/bootstrap-cli.md @@ -0,0 +1,45 @@ +# Bootstrap CLI + +Le package `bootstrap` reste optionnel : une application peut l'utiliser pour uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique. + +Exemple minimal : + +```go +func main() { + err := bootstrap.Run(context.Background(), bootstrap.Options{ + BinaryName: "my-mcp", + Description: "Client MCP", + Version: version, + EnableDoctorAlias: true, // expose `doctor` comme alias de `config test` + AliasDescriptions: map[string]string{ + "doctor": "Diagnostiquer la configuration locale.", + }, + Hooks: bootstrap.Hooks{ + Setup: func(ctx context.Context, inv bootstrap.Invocation) error { + return runSetup(ctx, inv.Args) + }, + MCP: func(ctx context.Context, inv bootstrap.Invocation) error { + return runMCP(ctx, inv.Args) + }, + ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error { + return runConfigShow(ctx, inv.Args) + }, + ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error { + return runConfigTest(ctx, inv.Args) + }, + Update: func(ctx context.Context, inv bootstrap.Invocation) error { + return runUpdate(ctx, inv.Args) + }, + }, + }) + if err != nil { + log.Fatal(err) + } +} +``` + +Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`. + +Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). +La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). +Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. diff --git a/docs/cli-helpers.md b/docs/cli-helpers.md new file mode 100644 index 0000000..4077f15 --- /dev/null +++ b/docs/cli-helpers.md @@ -0,0 +1,187 @@ +# Helpers CLI + +`cli` fournit des helpers simples pour les assistants interactifs : + +```go +profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) + +baseURL, err := cli.PromptLine(reader, os.Stdout, "Base URL", profile.BaseURL) +if err != nil { + return err +} +if err := cli.ValidateBaseURL(baseURL); err != nil { + return err +} + +token, err := cli.PromptSecret(os.Stdin, os.Stdout, "API token", hasStoredSecret, storedToken) +if err != nil { + return err +} +_ = token +``` + +Pour décrire un setup complet sans réécrire la boucle interactive : + +```go +result, err := cli.RunSetup(cli.SetupOptions{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Fields: []cli.SetupField{ + { + Name: "base_url", + Label: "Base URL", + Type: cli.SetupFieldURL, + Required: true, + }, + { + Name: "api_token", + Label: "API token", + Type: cli.SetupFieldSecret, + Required: true, + ExistingSecret: storedToken, + }, + { + Name: "enabled", + Label: "Enable integration", + Type: cli.SetupFieldBool, + Default: "true", + }, + { + Name: "scopes", + Label: "Scopes", + Type: cli.SetupFieldList, + Default: "read,write", + Normalize: func(value string) string { return strings.TrimSpace(strings.ToLower(value)) }, + }, + }, +}) +if err != nil { + return err +} + +baseURL, _ := result.Get("base_url") +apiToken, _ := result.Get("api_token") +enabled, _ := result.Get("enabled") +scopes, _ := result.Get("scopes") + +if apiToken.KeptStoredSecret { + fmt.Println("Stored token kept.") +} + +_ = baseURL +_ = enabled +_ = scopes +``` + +Chaque champ peut déclarer ses propres hooks de validation (`Validate`, `ValidateBool`, `ValidateList`). +Les validations sont appliquées de manière cohérente en TTY et en stdin non interactif. + +Pour standardiser la résolution `flag > env > config > secret` avec provenance : + +```go +store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + ServiceName: "my-mcp", +}) +if err != nil { + return err +} + +lookup := cli.ResolveLookup(cli.ResolveLookupOptions{ + Flag: cli.MapLookup(flagValues), + Env: cli.EnvLookup(os.LookupEnv), + Config: cli.ConfigMap(configValues), + Secret: cli.SecretStore(store), +}) + +resolution, err := cli.ResolveFields(cli.ResolveOptions{ + Fields: []cli.FieldSpec{ + {Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"}, + {Name: "api_token", Required: true, EnvKey: "MY_MCP_API_TOKEN", SecretKey: "my-mcp-api-token"}, + {Name: "timeout", DefaultValue: "30s"}, + }, + Lookup: lookup, +}) +if err != nil { + return err +} + +if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil { + return err +} +``` + +`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et `FieldSpec.Sources` permet de définir un ordre spécifique pour un champ. + +Le package fournit aussi un socle réutilisable pour une commande `doctor`. +L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), mais peut réutiliser les checks communs et ajouter ses propres hooks : + +```go +report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ + ConfigCheck: cli.NewConfigCheck(store), + SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) { + return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + ServiceName: "my-mcp", + }) + }), + RequiredSecrets: []cli.DoctorSecret{ + {Name: "api-token", Label: "API token"}, + }, + SecretStoreFactory: func() (secretstore.Store, error) { + return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + ServiceName: "my-mcp", + }) + }, + ManifestDir: ".", + ConnectivityCheck: func(context.Context) cli.DoctorResult { + if err := pingBackend(); err != nil { + return cli.DoctorResult{ + Name: "connectivity", + Status: cli.DoctorStatusFail, + Summary: "backend is unreachable", + Detail: err.Error(), + } + } + return cli.DoctorResult{ + Name: "connectivity", + Status: cli.DoctorStatusOK, + Summary: "backend is reachable", + } + }, +}) + +if err := cli.RenderDoctorReport(os.Stdout, report); err != nil { + return err +} + +if report.HasFailures() { + os.Exit(1) +} +``` + +Pour éviter des checks custom répétitifs sur les profils, `doctor` expose aussi un helper basé sur `FieldSpec` : + +```go +report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ + ExtraChecks: []cli.DoctorCheck{ + cli.RequiredResolvedFieldsCheck(cli.ResolveOptions{ + Fields: []cli.FieldSpec{ + {Name: "base_url", Required: true, EnvKey: "MY_MCP_BASE_URL"}, + { + Name: "api_token", + Required: true, + EnvKey: "MY_MCP_API_TOKEN", + SecretKey: "my-mcp-api-token", + Sources: []cli.ValueSource{cli.SourceEnv, cli.SourceSecret}, + }, + }, + Lookup: cli.ResolveLookup(cli.ResolveLookupOptions{ + Env: cli.EnvLookup(os.LookupEnv), + Config: cli.ConfigMap(configValues), + Secret: cli.SecretStore(secretStore), + }), + }), + }, +}) +``` + +En cas d'échec de résolution, tu peux aussi réutiliser le formatteur `cli.FormatResolveFieldsError(err)` dans un check custom pour garder des messages homogènes. diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..863f14c --- /dev/null +++ b/docs/config.md @@ -0,0 +1,64 @@ +# Config JSON + +Le package `config` stocke une structure générique par profil dans un JSON privé pour l'utilisateur courant. + +Exemple : + +```go +type Profile struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` +} + +store := config.NewStore[Profile]("my-mcp") + +cfg, path, err := store.LoadDefault() +if err != nil { + return err +} + +profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) +profile := cfg.Profiles[profileName] + +profile.BaseURL = "https://api.example.com" +cfg.CurrentProfile = profileName +cfg.Profiles[profileName] = profile + +_, err = store.SaveDefault(cfg) +if err != nil { + return err +} + +fmt.Printf("config saved to %s\n", path) +``` + +Notes : + +- le fichier est créé avec des permissions `0600` +- le répertoire parent est forcé en `0700` +- l'écriture est atomique via un fichier temporaire puis `rename` +- si le fichier n'existe pas, `Load` et `LoadDefault` retournent une config vide par défaut +- `NewStoreWithOptions` permet de définir une version cible, des migrations JSON (`from -> to`) et une validation explicite après chargement +- une config plus récente que la version supportée, ou sans chemin de migration complet, retourne une erreur explicite + +Exemple de store versionné : + +```go +store := config.NewStoreWithOptions[Profile]("my-mcp", config.Options[Profile]{ + Version: 2, + Migrations: map[int]config.Migration{ + 1: func(doc map[string]json.RawMessage) error { + return nil + }, + }, + Validator: func(cfg config.FileConfig[Profile]) []config.ValidationIssue { + if cfg.CurrentProfile == "" { + return []config.ValidationIssue{{ + Path: "current_profile", + Message: "must not be empty", + }} + } + return nil + }, +}) +``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..9aef506 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,40 @@ +# Installation et utilisation type + +## Installation + +```bash +go get gitea.lclr.dev/AI/mcp-framework +``` + +## CLI de scaffold + +Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go : + +```bash +go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest +mcp-framework scaffold init \ + --target ./my-mcp \ + --module example.com/my-mcp \ + --binary my-mcp \ + --profiles dev,prod +``` + +Puis dans le projet généré : + +```bash +cd my-mcp +go mod tidy +go run ./cmd/my-mcp help +``` + +## Utilisation type + +Le flux typique côté application est : + +1. Déclarer les sous-commandes communes via `bootstrap` (optionnel). +2. Résoudre le profil actif avec `cli`. +3. Charger la config versionnée avec `config`. +4. Lire les secrets avec `secretstore`. +5. Charger `mcp.toml` avec `manifest`. +6. Exécuter l'auto-update avec `update` si nécessaire. +7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. diff --git a/docs/limitations.md b/docs/limitations.md new file mode 100644 index 0000000..773309a --- /dev/null +++ b/docs/limitations.md @@ -0,0 +1,3 @@ +# Limites actuelles + +- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes diff --git a/docs/manifest.md b/docs/manifest.md new file mode 100644 index 0000000..6efd016 --- /dev/null +++ b/docs/manifest.md @@ -0,0 +1,83 @@ +# Manifeste `mcp.toml` + +Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire 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" +driver = "gitea" +repository = "org/repo" +base_url = "https://gitea.example.com" +asset_name_template = "{binary}-{os}-{arch}{ext}" +checksum_asset_name = "{asset}.sha256" +checksum_required = true +signature_asset_name = "{asset}.sig" +signature_required = false +signature_public_key_env_names = ["MY_MCP_RELEASE_ED25519_PUBLIC_KEY"] +token_header = "Authorization" +token_prefix = "token" +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 : + +- `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. +- `driver` : driver de forge (`gitea`, `gitlab`, `github`) pour déduire automatiquement l'endpoint latest. +- `repository` : dépôt cible (`org/repo` ou `group/subgroup/repo`). +- `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 (prioritaire sur le driver). +- `asset_name_template` : template de nom d'asset (`{binary}`, `{os}`, `{arch}`, `{ext}`). +- `checksum_asset_name` : nom d'asset checksum, avec placeholder optionnel `{asset}`. +- `checksum_required` : si `true`, l'update échoue quand l'asset checksum est absent. +- `signature_asset_name` : nom d'asset signature Ed25519 (détachée), avec placeholder optionnel `{asset}`. +- `signature_required` : si `true`, l'update échoue si la signature ou la clé publique manquent, ou si la signature est invalide. +- `signature_public_key` : clé publique Ed25519 (hex ou base64) utilisée pour vérifier la signature. +- `signature_public_key_env_names` : variables d'environnement candidates contenant la clé publique Ed25519. +- `token_header` : header HTTP à utiliser pour l'authentification. +- `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...). +- `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 : + +```go +file, path, err := manifest.LoadDefault(".") +if err != nil { + return err +} + +fmt.Printf("manifest loaded from %s\n", path) +source := file.Update.ReleaseSource() +bootstrapInfo := file.BootstrapInfo() +scaffoldInfo := file.ScaffoldInfo() +_ = bootstrapInfo +_ = scaffoldInfo +_ = source +``` diff --git a/docs/minimal-example.md b/docs/minimal-example.md new file mode 100644 index 0000000..ce0b185 --- /dev/null +++ b/docs/minimal-example.md @@ -0,0 +1,36 @@ +# Exemple minimal + +```go +type Profile struct { + BaseURL string `json:"base_url"` +} + +func run(ctx context.Context, flagProfile string) error { + cfgStore := config.NewStore[Profile]("my-mcp") + + cfg, _, err := cfgStore.LoadDefault() + if err != nil { + return err + } + + profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) + profile := cfg.Profiles[profileName] + + manifestFile, _, err := manifest.LoadDefault(".") + if err != nil { + return err + } + + err = update.Run(ctx, update.Options{ + CurrentVersion: version, + BinaryName: "my-mcp", + ReleaseSource: manifestFile.Update.ReleaseSource(), + }) + if err != nil { + return err + } + + _ = profile + return nil +} +``` diff --git a/docs/packages.md b/docs/packages.md new file mode 100644 index 0000000..12aae8a --- /dev/null +++ b/docs/packages.md @@ -0,0 +1,9 @@ +# Packages + +- `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, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. +- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage). +- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`. +- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. diff --git a/docs/scaffolding.md b/docs/scaffolding.md new file mode 100644 index 0000000..ce3763f --- /dev/null +++ b/docs/scaffolding.md @@ -0,0 +1,26 @@ +# Scaffolding + +Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : + +- arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) +- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP) +- wiring initial `bootstrap + config + secretstore + update` +- `README.md` de démarrage + +Exemple : + +```go +result, err := scaffold.Generate(scaffold.Options{ + TargetDir: "./my-mcp", + ModulePath: "gitea.lclr.dev/AI/my-mcp", + BinaryName: "my-mcp", + Description: "Client MCP interne", + DefaultProfile: "prod", + Profiles: []string{"dev", "prod"}, +}) +if err != nil { + return err +} + +fmt.Printf("Scaffold generated in %s (%d files)\n", result.Root, len(result.Files)) +``` diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 0000000..522d48d --- /dev/null +++ b/docs/secrets.md @@ -0,0 +1,89 @@ +# Secrets + +Le package `secretstore` supporte plusieurs politiques de backend : + +- `auto` : comportement par défaut, utilise un backend keyring disponible et peut retomber sur l'environnement si `LookupEnv` est fourni +- `kwallet-only` : impose KWallet et retourne une erreur explicite si KWallet n'est pas disponible +- `keyring-any` : impose l'utilisation d'un backend keyring disponible +- `env-only` : lecture seule depuis les variables d'environnement + +Backends keyring typiques : + +- macOS : Keychain +- Linux : Secret Service ou KWallet selon l'environnement +- Windows : Credential Manager + +Ouverture recommandée depuis la policy du manifeste runtime (`mcp.toml` résolu près de l'exécutable) : + +```go +store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + ServiceName: "my-mcp", + LookupEnv: os.LookupEnv, +}) +if err != nil { + return err +} +``` + +Exemple bas niveau : + +```go +store, err := secretstore.Open(secretstore.Options{ + ServiceName: "my-mcp", + BackendPolicy: secretstore.BackendAuto, +}) +if err != nil { + return err +} + +if err := store.SetSecret("api-token", "My MCP API token", token); err != nil { + return err +} + +token, err = store.GetSecret("api-token") +switch { +case err == nil: + // secret found +case errors.Is(err, secretstore.ErrNotFound): + // first run +default: + return err +} +``` + +Pour imposer KWallet sur Linux : + +```go +store, err := secretstore.Open(secretstore.Options{ + ServiceName: "email-mcp", + BackendPolicy: secretstore.BackendKWalletOnly, +}) +``` + +Pour stocker un secret structuré en JSON : + +```go +type Credentials struct { + Host string `json:"host"` + Username string `json:"username"` + Password string `json:"password"` +} + +err = secretstore.SetJSON(store, "imap-credentials", "IMAP credentials", Credentials{ + Host: "imap.example.com", + Username: "alice", + Password: token, +}) +if err != nil { + return err +} + +creds, err := secretstore.GetJSON[Credentials](store, "imap-credentials") +if err != nil { + return err +} +_ = creds +``` + +En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`. +Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`. -- 2.45.2 From 01c0c7e1bc11a0f44c4584be4204cbbf37a008fe Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 14:23:15 +0200 Subject: [PATCH 29/79] fix(update): reject HTML artifacts during self-update --- update/update.go | 52 ++++++++++++++++++++ update/update_test.go | 108 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/update/update.go b/update/update.go index 21aa789..31c60f5 100644 --- a/update/update.go +++ b/update/update.go @@ -1,6 +1,7 @@ package update import ( + "bytes" "context" "crypto/ed25519" "crypto/sha256" @@ -23,6 +24,7 @@ import ( const defaultAssetNameTemplate = "{binary}-{os}-{arch}{ext}" const defaultMaxDownloadBytes int64 = 200 * 1024 * 1024 +const downloadedArtifactSniffBytes = 4096 type Options struct { Client *http.Client @@ -179,6 +181,10 @@ func Run(ctx context.Context, opts Options) error { } defer os.Remove(downloadPath) + if err := validateDownloadedArtifact(downloadPath, assetName); err != nil { + return err + } + if err := VerifyReleaseAssetChecksum(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil { return err } @@ -211,6 +217,52 @@ func Run(ctx context.Context, opts Options) error { return nil } +func validateDownloadedArtifact(path, assetName string) error { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("validate downloaded artifact %q: %w", path, err) + } + defer file.Close() + + head := make([]byte, downloadedArtifactSniffBytes) + n, readErr := file.Read(head) + if readErr != nil && !errors.Is(readErr, io.EOF) { + return fmt.Errorf("validate downloaded artifact %q: %w", path, readErr) + } + if n == 0 { + return fmt.Errorf("downloaded artifact %q is empty", assetName) + } + + if looksLikeHTMLDocument(head[:n]) { + return fmt.Errorf( + "downloaded artifact %q looks like an HTML page (possible auth/forbidden response)", + assetName, + ) + } + + return nil +} + +func looksLikeHTMLDocument(content []byte) bool { + if len(content) == 0 { + return false + } + + detectedContentType := strings.ToLower(http.DetectContentType(content)) + if strings.HasPrefix(detectedContentType, "text/html") { + return true + } + + trimmed := bytes.TrimSpace(content) + trimmed = bytes.TrimPrefix(trimmed, []byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM + if len(trimmed) == 0 { + return false + } + + lower := strings.ToLower(string(trimmed)) + return strings.HasPrefix(lower, "ForbiddenAccess denied" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + err := validateDownloadedArtifact(path, "graylog-mcp-linux-amd64") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "looks like an HTML page") { + t.Fatalf("error = %v", err) + } +} + +func TestValidateDownloadedArtifactAcceptsShebangScript(t *testing.T) { + path := filepath.Join(t.TempDir(), "downloaded") + content := "#!/usr/bin/env sh\necho ok\n" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + err := validateDownloadedArtifact(path, "graylog-mcp-linux-amd64") + if err != nil { + t.Fatalf("validateDownloadedArtifact: %v", err) + } +} + func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("self-replace is not supported on windows") @@ -516,6 +545,85 @@ func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { } } +func TestRunStopsWhenArtifactLooksLikeHTML(t *testing.T) { + assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.String() { + case "https://releases.example.com/latest": + release := Release{TagName: "v1.2.3"} + release.Assets.Links = []ReleaseLink{ + {Name: assetName, URL: "https://releases.example.com/artifact"}, + } + + payload, marshalErr := json.Marshal(release) + if marshalErr != nil { + t.Fatalf("Marshal release: %v", marshalErr) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil + case "https://releases.example.com/artifact": + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + "Access denied", + )), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + replaceCalled := false + err = Run(context.Background(), Options{ + Client: client, + CurrentVersion: "v1.2.2", + ExecutablePath: target, + LatestReleaseURL: "https://releases.example.com/latest", + BinaryName: "graylog-mcp", + ReplaceExecutable: func(downloadPath, targetPath string) error { + replaceCalled = true + return os.WriteFile(targetPath, []byte("unexpected"), 0o755) + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "looks like an HTML page") { + t.Fatalf("error = %v", err) + } + if replaceCalled { + t.Fatal("replace hook should not have been called") + } + + got, readErr := os.ReadFile(target) + if readErr != nil { + t.Fatalf("ReadFile target: %v", readErr) + } + if string(got) != "old-binary" { + t.Fatalf("target content = %q, want unchanged binary", string(got)) + } +} + func TestRunUsesDriverWithoutExplicitLatestReleaseURL(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { -- 2.45.2 From a9378885f2a863fa1c8bf0edb1814a6c60fa9aaf Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Thu, 16 Apr 2026 16:56:00 +0200 Subject: [PATCH 30/79] feat(manifest): add embedded runtime fallback for scaffolded apps --- cli/doctor.go | 7 +++- cli/doctor_test.go | 25 ++++++++++++ docs/getting-started.md | 2 +- docs/manifest.md | 12 ++++++ docs/minimal-example.md | 4 +- docs/packages.md | 2 +- manifest/manifest.go | 33 +++++++++++++++- manifest/manifest_test.go | 61 +++++++++++++++++++++++++++++ scaffold/scaffold.go | 81 ++++++++++++++++++++++++++++++++++----- scaffold/scaffold_test.go | 7 +++- 10 files changed, 217 insertions(+), 17 deletions(-) diff --git a/cli/doctor.go b/cli/doctor.go index 8dfca94..27b15a8 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -55,6 +55,7 @@ type DoctorOptions struct { SecretStoreFactory func() (secretstore.Store, error) ManifestDir string ManifestValidator DoctorManifestValidator + ManifestCheck DoctorCheck ConnectivityCheck DoctorCheck ExtraChecks []DoctorCheck } @@ -71,7 +72,11 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport { if len(options.RequiredSecrets) > 0 && options.SecretStoreFactory != nil { checks = append(checks, RequiredSecretsCheck(options.SecretStoreFactory, options.RequiredSecrets)) } - checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator)) + if options.ManifestCheck != nil { + checks = append(checks, options.ManifestCheck) + } else { + checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator)) + } if options.ConnectivityCheck != nil { checks = append(checks, options.ConnectivityCheck) } diff --git a/cli/doctor_test.go b/cli/doctor_test.go index 04dc23f..04aeb99 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -132,6 +132,31 @@ func TestManifestCheckUsesValidator(t *testing.T) { } } +func TestRunDoctorUsesCustomManifestCheckWhenProvided(t *testing.T) { + report := RunDoctor(context.Background(), DoctorOptions{ + ManifestDir: t.TempDir(), + ManifestCheck: func(context.Context) DoctorResult { + return DoctorResult{ + Name: "manifest", + Status: DoctorStatusOK, + Summary: "manifest is embedded", + Detail: "embedded:mcp.toml", + } + }, + }) + + if len(report.Results) != 1 { + t.Fatalf("result count = %d, want 1", len(report.Results)) + } + result := report.Results[0] + if result.Summary != "manifest is embedded" { + t.Fatalf("summary = %q", result.Summary) + } + if result.Detail != "embedded:mcp.toml" { + t.Fatalf("detail = %q", result.Detail) + } +} + func TestRenderDoctorReportFormatsStatusesAndSummary(t *testing.T) { var out bytes.Buffer report := DoctorReport{ diff --git a/docs/getting-started.md b/docs/getting-started.md index 9aef506..b146f73 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -35,6 +35,6 @@ Le flux typique côté application est : 2. Résoudre le profil actif avec `cli`. 3. Charger la config versionnée avec `config`. 4. Lire les secrets avec `secretstore`. -5. Charger `mcp.toml` avec `manifest`. +5. Charger le manifest runtime avec `manifest` (`mcp.toml` local, ou fallback embarqué). 6. Exécuter l'auto-update avec `update` si nécessaire. 7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. diff --git a/docs/manifest.md b/docs/manifest.md index 6efd016..0e3b064 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -1,6 +1,7 @@ # Manifeste `mcp.toml` Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire courant puis remonte les répertoires parents jusqu'à trouver le fichier. +Pour un binaire installé (par exemple dans `~/.local/bin`), il peut aussi charger un fallback embarqué via `LoadDefaultOrEmbedded`. Exemple minimal : @@ -81,3 +82,14 @@ _ = bootstrapInfo _ = scaffoldInfo _ = source ``` + +Fallback fichier puis embarqué : + +```go +file, source, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest) +if err != nil { + return err +} + +fmt.Printf("manifest source: %s\n", source) // chemin du fichier ou "embedded:mcp.toml" +``` diff --git a/docs/minimal-example.md b/docs/minimal-example.md index ce0b185..2cf682d 100644 --- a/docs/minimal-example.md +++ b/docs/minimal-example.md @@ -5,6 +5,8 @@ type Profile struct { BaseURL string `json:"base_url"` } +var embeddedManifest = `...` // fallback utilisé si aucun mcp.toml runtime n'est trouvé + func run(ctx context.Context, flagProfile string) error { cfgStore := config.NewStore[Profile]("my-mcp") @@ -16,7 +18,7 @@ func run(ctx context.Context, flagProfile string) error { profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) profile := cfg.Profiles[profileName] - manifestFile, _, err := manifest.LoadDefault(".") + manifestFile, _, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest) if err != nil { return err } diff --git a/docs/packages.md b/docs/packages.md index 12aae8a..4c181c2 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -3,7 +3,7 @@ - `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, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. +- `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. - `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage). - `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. diff --git a/manifest/manifest.go b/manifest/manifest.go index 544e59a..b35dcf5 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -13,6 +13,7 @@ import ( ) const DefaultFile = "mcp.toml" +const EmbeddedSource = "embedded:mcp.toml" type File struct { BinaryName string `toml:"binary_name"` @@ -118,9 +119,39 @@ func Load(path string) (File, error) { return File{}, fmt.Errorf("read manifest %s: %w", path, err) } + return parse(data, path) +} + +func LoadEmbedded(content string) (File, string, error) { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return File{}, "", os.ErrNotExist + } + + file, err := parse([]byte(trimmed), EmbeddedSource) + if err != nil { + return File{}, "", err + } + + return file, EmbeddedSource, nil +} + +func LoadDefaultOrEmbedded(startDir, embeddedContent string) (File, string, error) { + file, path, err := LoadDefault(startDir) + if err == nil { + return file, path, nil + } + if !errors.Is(err, os.ErrNotExist) { + return File{}, "", err + } + + return LoadEmbedded(embeddedContent) +} + +func parse(data []byte, source string) (File, error) { var file File if err := toml.Unmarshal(data, &file); err != nil { - return File{}, fmt.Errorf("parse manifest %s: %w", path, err) + return File{}, fmt.Errorf("parse manifest %s: %w", source, err) } file.normalize() diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 5d6a739..46b0ec0 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -234,3 +234,64 @@ description = " Client MCP interne " t.Fatalf("scaffold known environment variables = %v", scaffold.KnownEnvironmentVariables) } } + +func TestLoadEmbeddedParsesContent(t *testing.T) { + file, source, err := LoadEmbedded(` +[update] +latest_release_url = "https://example.com/latest" +`) + if err != nil { + t.Fatalf("LoadEmbedded returned error: %v", err) + } + if source != EmbeddedSource { + t.Fatalf("source = %q, want %q", source, EmbeddedSource) + } + if file.Update.LatestReleaseURL != "https://example.com/latest" { + t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL) + } +} + +func TestLoadEmbeddedReturnsNotExistWhenEmpty(t *testing.T) { + _, _, err := LoadEmbedded(" ") + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("error = %v, want os.ErrNotExist", err) + } +} + +func TestLoadDefaultOrEmbeddedPrefersManifestFile(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, DefaultFile) + if err := os.WriteFile(path, []byte("[update]\nlatest_release_url = \"https://example.com/from-file\"\n"), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + file, source, err := LoadDefaultOrEmbedded(root, ` +[update] +latest_release_url = "https://example.com/from-embedded" +`) + if err != nil { + t.Fatalf("LoadDefaultOrEmbedded returned error: %v", err) + } + if source != path { + t.Fatalf("source = %q, want %q", source, path) + } + if file.Update.LatestReleaseURL != "https://example.com/from-file" { + t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL) + } +} + +func TestLoadDefaultOrEmbeddedUsesEmbeddedWhenFileMissing(t *testing.T) { + file, source, err := LoadDefaultOrEmbedded(t.TempDir(), ` +[update] +latest_release_url = "https://example.com/from-embedded" +`) + if err != nil { + t.Fatalf("LoadDefaultOrEmbedded returned error: %v", err) + } + if source != EmbeddedSource { + t.Fatalf("source = %q, want %q", source, EmbeddedSource) + } + if file.Update.LatestReleaseURL != "https://example.com/from-embedded" { + t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL) + } +} diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 81e9fe7..1300206 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -888,6 +888,38 @@ import ( "gitea.lclr.dev/AI/mcp-framework/update" ) +var embeddedManifest = ` + "`" + `binary_name = "{{.BinaryName}}" +docs_url = "{{.DocsURL}}" + +[update] +source_name = "Release endpoint" +driver = "{{.ReleaseDriver}}" +repository = "{{.ReleaseRepository}}" +base_url = "{{.ReleaseBaseURL}}" +asset_name_template = "{binary}-{os}-{arch}{ext}" +checksum_asset_name = "{asset}.sha256" +checksum_required = true +signature_asset_name = "{asset}.sig" +signature_required = false +signature_public_key_env_names = ["{{.ReleasePublicKeyEnv}}"] +token_header = "Authorization" +token_prefix = "token" +token_env_names = ["{{.ReleaseTokenEnv}}"] + +[environment] +known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] + +[secret_store] +backend_policy = "{{.SecretStorePolicy}}" + +[profiles] +default = "{{.DefaultProfile}}" +known = [{{- range $index, $value := .Profiles}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] + +[bootstrap] +description = "{{.Description}}" +` + "`" + ` + type Profile struct { BaseURL string } @@ -895,6 +927,7 @@ type Profile struct { type Runtime struct { ConfigStore config.Store[Profile] Manifest manifest.File + ManifestSource string BinaryName string Description string Version string @@ -921,12 +954,13 @@ func NewRuntime(version string) (Runtime, error) { } } - manifestFile, _, err := manifest.LoadDefault(manifestStartDir) + manifestFile, manifestSource, err := manifest.LoadDefaultOrEmbedded(manifestStartDir, embeddedManifest) if err != nil { if !errors.Is(err, os.ErrNotExist) { return Runtime{}, err } manifestFile = manifest.File{} + manifestSource = "" } bootstrapInfo := manifestFile.BootstrapInfo() @@ -944,13 +978,14 @@ func NewRuntime(version string) (Runtime, error) { tokenEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[2], tokenEnv) } - return Runtime{ - ConfigStore: config.NewStore[Profile](binaryName), - Manifest: manifestFile, - BinaryName: binaryName, - Description: description, - Version: firstNonEmpty(strings.TrimSpace(version), "dev"), - DefaultProfile: defaultProfile, + return Runtime{ + ConfigStore: config.NewStore[Profile](binaryName), + Manifest: manifestFile, + ManifestSource: manifestSource, + BinaryName: binaryName, + Description: description, + Version: firstNonEmpty(strings.TrimSpace(version), "dev"), + DefaultProfile: defaultProfile, ProfileEnv: profileEnv, TokenEnv: tokenEnv, SecretName: binaryName + "-api-token", @@ -1113,7 +1148,7 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er {Name: r.SecretName, Label: "API token"}, }, SecretStoreFactory: r.openSecretStore, - ManifestDir: ".", + ManifestCheck: r.manifestDoctorCheck(), }) if err := cli.RenderDoctorReport(stdout, report); err != nil { @@ -1142,8 +1177,14 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error } func (r Runtime) openSecretStore() (secretstore.Store, error) { - return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + backendPolicy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy)) + if backendPolicy == "" { + backendPolicy = secretstore.BackendAuto + } + + return secretstore.Open(secretstore.Options{ ServiceName: r.BinaryName, + BackendPolicy: backendPolicy, LookupEnv: func(name string) (string, bool) { if name == r.SecretName { return os.LookupEnv(r.TokenEnv) @@ -1179,6 +1220,26 @@ func firstNonEmpty(values ...string) string { } return "" } + +func (r Runtime) manifestDoctorCheck() cli.DoctorCheck { + return func(context.Context) cli.DoctorResult { + source := strings.TrimSpace(r.ManifestSource) + if source == "" { + return cli.DoctorResult{ + Name: "manifest", + Status: cli.DoctorStatusWarn, + Summary: "manifest is missing, using built-in defaults", + } + } + + return cli.DoctorResult{ + Name: "manifest", + Status: cli.DoctorStatusOK, + Summary: "manifest is valid", + Detail: source, + } + } +} ` const manifestTemplate = `binary_name = "{{.BinaryName}}" diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index be726db..2f59ace 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -66,12 +66,15 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { } for _, snippet := range []string{ "config.NewStore[Profile]", - "secretstore.OpenFromManifest", + "secretstore.Open(secretstore.Options", "update.Run", - "manifest.LoadDefault", + "manifest.LoadDefaultOrEmbedded", "bootstrap.Run", "os.Executable()", "errors.Is(err, os.ErrNotExist)", + `var embeddedManifest = `, + "ManifestSource", + "ManifestCheck: r.manifestDoctorCheck()", } { if !strings.Contains(string(appGo), snippet) { t.Fatalf("app.go missing snippet %q", snippet) -- 2.45.2 From 973770ed7848518ccd93e5702f277e1eb72cf7ba Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Thu, 16 Apr 2026 17:52:25 +0200 Subject: [PATCH 31/79] feat(scaffold): install binaries from latest release in install script --- scaffold/scaffold.go | 706 ++++++++++++++++++++++++++++++++++++-- scaffold/scaffold_test.go | 7 +- 2 files changed, 688 insertions(+), 25 deletions(-) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 1300206..135f4af 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -357,6 +357,30 @@ BINARY_NAME="{{.BinaryName}}" MODULE_PATH="{{.ModulePath}}" DEFAULT_PROFILE="{{.DefaultProfile}}" PROFILE_ENV="{{.ProfileEnv}}" +DEFAULT_RELEASE_DRIVER="{{.ReleaseDriver}}" +DEFAULT_RELEASE_BASE_URL="{{.ReleaseBaseURL}}" +DEFAULT_RELEASE_REPOSITORY="{{.ReleaseRepository}}" +DEFAULT_ASSET_NAME_TEMPLATE="{binary}-{os}-{arch}{ext}" +DEFAULT_CHECKSUM_ASSET_NAME="{asset}.sha256" +DEFAULT_CHECKSUM_REQUIRED="true" +DEFAULT_TOKEN_HEADER="Authorization" +DEFAULT_TOKEN_PREFIX="token" +DEFAULT_TOKEN_ENV_NAME="{{.ReleaseTokenEnv}}" +RELEASE_DRIVER="$DEFAULT_RELEASE_DRIVER" +RELEASE_BASE_URL="$DEFAULT_RELEASE_BASE_URL" +RELEASE_REPOSITORY="$DEFAULT_RELEASE_REPOSITORY" +LATEST_RELEASE_URL="" +ASSET_NAME_TEMPLATE="$DEFAULT_ASSET_NAME_TEMPLATE" +CHECKSUM_ASSET_NAME="$DEFAULT_CHECKSUM_ASSET_NAME" +CHECKSUM_REQUIRED="$DEFAULT_CHECKSUM_REQUIRED" +TOKEN_HEADER="$DEFAULT_TOKEN_HEADER" +TOKEN_PREFIX="$DEFAULT_TOKEN_PREFIX" +AUTH_HEADER_NAME="" +AUTH_HEADER_VALUE="" +TOKEN_ENV_NAMES=() +if [ -n "$DEFAULT_TOKEN_ENV_NAME" ]; then + TOKEN_ENV_NAMES=("$DEFAULT_TOKEN_ENV_NAME") +fi PREFILL_SERVER_NAME="" PREFILL_PROFILE_VALUE="" PREFILL_COMMAND_PATH="" @@ -543,15 +567,546 @@ toml_escape() { printf "%s" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' } -go_bin_dir() { - local gobin - gobin="$(go env GOBIN 2>/dev/null || true)" - if [ -n "$gobin" ]; then - printf "%s\n" "$gobin" +trim_whitespace() { + printf "%s" "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' +} + +unquote_toml() { + local value + value="$(trim_whitespace "$1")" + if [ "${#value}" -ge 2 ]; then + case "$value" in + \"*\") + value="${value#\"}" + value="${value%\"}" + ;; + \'*\') + value="${value#\'}" + value="${value%\'}" + ;; + esac + fi + printf "%s" "$value" +} + +normalize_bool() { + local value + value="$(printf "%s" "$1" | tr '[:upper:]' '[:lower:]')" + value="$(trim_whitespace "$value")" + case "$value" in + true|1|yes|y) + printf "true" + ;; + false|0|no|n) + printf "false" + ;; + *) + printf "%s" "$2" + ;; + esac +} + +toml_read_update_value() { + local key="$1" + awk -v key="$key" ' + BEGIN { + in_update = 0 + } + { + line = $0 + sub(/[[:space:]]*#.*/, "", line) + if (line ~ /^[[:space:]]*\[/) { + if (line ~ /^[[:space:]]*\[update\][[:space:]]*$/) { + in_update = 1 + } else { + in_update = 0 + } + } + if (in_update == 1 && line ~ "^[[:space:]]*" key "[[:space:]]*=") { + sub("^[[:space:]]*" key "[[:space:]]*=[[:space:]]*", "", line) + print line + exit + } + } + ' mcp.toml +} + +toml_read_update_array() { + local key="$1" + local raw + raw="$(toml_read_update_value "$key")" + raw="$(trim_whitespace "$raw")" + if [ -z "$raw" ]; then return fi - go env GOPATH 2>/dev/null | awk '{print $1 "/bin"}' + raw="${raw#\[}" + raw="${raw%\]}" + local items=() + local item="" + IFS=',' read -r -a items <<< "$raw" + for item in "${items[@]}"; do + item="$(unquote_toml "$item")" + item="$(trim_whitespace "$item")" + if [ -n "$item" ]; then + printf "%s\n" "$item" + fi + done +} + +normalize_release_config() { + RELEASE_DRIVER="$(printf "%s" "$RELEASE_DRIVER" | tr '[:upper:]' '[:lower:]')" + RELEASE_DRIVER="$(trim_whitespace "$RELEASE_DRIVER")" + + RELEASE_REPOSITORY="$(trim_whitespace "$RELEASE_REPOSITORY")" + RELEASE_REPOSITORY="${RELEASE_REPOSITORY#/}" + RELEASE_REPOSITORY="${RELEASE_REPOSITORY%/}" + + RELEASE_BASE_URL="$(trim_whitespace "$RELEASE_BASE_URL")" + RELEASE_BASE_URL="${RELEASE_BASE_URL%/}" + + LATEST_RELEASE_URL="$(trim_whitespace "$LATEST_RELEASE_URL")" + ASSET_NAME_TEMPLATE="$(trim_whitespace "$ASSET_NAME_TEMPLATE")" + CHECKSUM_ASSET_NAME="$(trim_whitespace "$CHECKSUM_ASSET_NAME")" + TOKEN_HEADER="$(trim_whitespace "$TOKEN_HEADER")" + TOKEN_PREFIX="$(trim_whitespace "$TOKEN_PREFIX")" + + if [ -z "$ASSET_NAME_TEMPLATE" ]; then + ASSET_NAME_TEMPLATE="$DEFAULT_ASSET_NAME_TEMPLATE" + fi + if [ -z "$CHECKSUM_ASSET_NAME" ]; then + CHECKSUM_ASSET_NAME="$DEFAULT_CHECKSUM_ASSET_NAME" + fi + + local filtered_env_names=() + local env_name="" + for env_name in "${TOKEN_ENV_NAMES[@]}"; do + env_name="$(trim_whitespace "$env_name")" + if [ -n "$env_name" ]; then + filtered_env_names+=("$env_name") + fi + done + TOKEN_ENV_NAMES=("${filtered_env_names[@]}") + + case "$RELEASE_DRIVER" in + gitea) + if [ -z "$TOKEN_HEADER" ]; then + TOKEN_HEADER="Authorization" + fi + if [ -z "$TOKEN_PREFIX" ]; then + TOKEN_PREFIX="token" + fi + if [ "${#TOKEN_ENV_NAMES[@]}" -eq 0 ]; then + TOKEN_ENV_NAMES=("GITEA_TOKEN") + fi + ;; + gitlab) + if [ -z "$RELEASE_BASE_URL" ]; then + RELEASE_BASE_URL="https://gitlab.com" + fi + if [ -z "$TOKEN_HEADER" ]; then + TOKEN_HEADER="PRIVATE-TOKEN" + fi + if [ "${#TOKEN_ENV_NAMES[@]}" -eq 0 ]; then + TOKEN_ENV_NAMES=("GITLAB_TOKEN" "GITLAB_PRIVATE_TOKEN") + fi + ;; + github) + if [ -z "$RELEASE_BASE_URL" ]; then + RELEASE_BASE_URL="https://api.github.com" + fi + if [ -z "$TOKEN_HEADER" ]; then + TOKEN_HEADER="Authorization" + fi + if [ -z "$TOKEN_PREFIX" ]; then + TOKEN_PREFIX="Bearer" + fi + if [ "${#TOKEN_ENV_NAMES[@]}" -eq 0 ]; then + TOKEN_ENV_NAMES=("GITHUB_TOKEN") + fi + ;; + esac +} + +load_release_config_from_manifest() { + if [ ! -f "mcp.toml" ]; then + ui_warn "mcp.toml introuvable: utilisation de la configuration release integree au script." + normalize_release_config + return + fi + + local value + value="$(toml_read_update_value "driver")" + if [ -n "$value" ]; then + RELEASE_DRIVER="$(printf "%s" "$(unquote_toml "$value")" | tr '[:upper:]' '[:lower:]')" + fi + + value="$(toml_read_update_value "repository")" + if [ -n "$value" ]; then + RELEASE_REPOSITORY="$(unquote_toml "$value")" + fi + + value="$(toml_read_update_value "base_url")" + if [ -n "$value" ]; then + RELEASE_BASE_URL="$(unquote_toml "$value")" + fi + + value="$(toml_read_update_value "latest_release_url")" + if [ -n "$value" ]; then + LATEST_RELEASE_URL="$(unquote_toml "$value")" + fi + + value="$(toml_read_update_value "asset_name_template")" + if [ -n "$value" ]; then + ASSET_NAME_TEMPLATE="$(unquote_toml "$value")" + fi + + value="$(toml_read_update_value "checksum_asset_name")" + if [ -n "$value" ]; then + CHECKSUM_ASSET_NAME="$(unquote_toml "$value")" + fi + + value="$(toml_read_update_value "checksum_required")" + if [ -n "$value" ]; then + CHECKSUM_REQUIRED="$(normalize_bool "$(unquote_toml "$value")" "$CHECKSUM_REQUIRED")" + fi + + value="$(toml_read_update_value "token_header")" + if [ -n "$value" ]; then + TOKEN_HEADER="$(unquote_toml "$value")" + fi + + value="$(toml_read_update_value "token_prefix")" + if [ -n "$value" ]; then + TOKEN_PREFIX="$(unquote_toml "$value")" + fi + + local parsed_token_env_names=() + local parsed_env_name="" + while IFS= read -r parsed_env_name; do + if [ -n "$parsed_env_name" ]; then + parsed_token_env_names+=("$parsed_env_name") + fi + done < <(toml_read_update_array "token_env_names") + if [ "${#parsed_token_env_names[@]}" -gt 0 ]; then + TOKEN_ENV_NAMES=("${parsed_token_env_names[@]}") + fi + + normalize_release_config +} + +resolve_goos() { + case "$(uname -s)" in + Linux) + printf "linux" + ;; + Darwin) + printf "darwin" + ;; + FreeBSD) + printf "freebsd" + ;; + OpenBSD) + printf "openbsd" + ;; + NetBSD) + printf "netbsd" + ;; + CYGWIN*|MINGW*|MSYS*) + printf "windows" + ;; + *) + ui_error "OS non supporte: $(uname -s)" + exit 1 + ;; + esac +} + +resolve_goarch() { + case "$(uname -m)" in + x86_64|amd64) + printf "amd64" + ;; + aarch64|arm64) + printf "arm64" + ;; + armv7*|armv6*|armhf) + printf "arm" + ;; + i386|i686) + printf "386" + ;; + ppc64le) + printf "ppc64le" + ;; + s390x) + printf "s390x" + ;; + riscv64) + printf "riscv64" + ;; + *) + ui_error "Architecture non supportee: $(uname -m)" + exit 1 + ;; + esac +} + +resolve_asset_name() { + local goos="$1" + local goarch="$2" + local ext="" + if [ "$goos" = "windows" ]; then + ext=".exe" + fi + + local template="$ASSET_NAME_TEMPLATE" + if [ -z "$template" ]; then + template="$DEFAULT_ASSET_NAME_TEMPLATE" + fi + + local asset_name="$template" + asset_name="${asset_name//\{binary\}/$BINARY_NAME}" + asset_name="${asset_name//\{os\}/$goos}" + asset_name="${asset_name//\{arch\}/$goarch}" + asset_name="${asset_name//\{ext\}/$ext}" + asset_name="$(trim_whitespace "$asset_name")" + + if [ -z "$asset_name" ]; then + ui_error "Le template d'asset resolve vers une valeur vide." + exit 1 + fi + + case "$asset_name" in + */*|*\\*) + ui_error "Nom d'asset invalide (separateur de chemin): $asset_name" + exit 1 + ;; + esac + + printf "%s" "$asset_name" +} + +resolve_latest_release_url() { + if [ -n "$LATEST_RELEASE_URL" ]; then + printf "%s" "$LATEST_RELEASE_URL" + return + fi + + if [ -z "$RELEASE_DRIVER" ]; then + ui_error "Configuration release incomplete: definir [update].driver ou [update].latest_release_url." + exit 1 + fi + + if [ -z "$RELEASE_REPOSITORY" ]; then + ui_error "Configuration release incomplete: definir [update].repository." + exit 1 + fi + + case "$RELEASE_DRIVER" in + gitea) + if [ -z "$RELEASE_BASE_URL" ]; then + ui_error "Configuration release incomplete: [update].base_url requis pour driver gitea." + exit 1 + fi + printf "%s/api/v1/repos/%s/releases/latest" "$RELEASE_BASE_URL" "$RELEASE_REPOSITORY" + ;; + gitlab) + local encoded_repository + encoded_repository="$(jq -nr --arg value "$RELEASE_REPOSITORY" '$value|@uri')" + printf "%s/api/v4/projects/%s/releases/permalink/latest" "$RELEASE_BASE_URL" "$encoded_repository" + ;; + github) + printf "%s/repos/%s/releases/latest" "$RELEASE_BASE_URL" "$RELEASE_REPOSITORY" + ;; + *) + ui_error "Driver release non supporte: $RELEASE_DRIVER (attendu: gitea, gitlab ou github)." + exit 1 + ;; + esac +} + +resolve_auth_header() { + AUTH_HEADER_NAME="" + AUTH_HEADER_VALUE="" + + local token="" + local env_name + for env_name in "${TOKEN_ENV_NAMES[@]}"; do + if [ -z "$env_name" ]; then + continue + fi + if [ -n "${!env_name:-}" ]; then + token="$(trim_whitespace "${!env_name}")" + if [ -n "$token" ]; then + break + fi + fi + done + + if [ -z "$token" ]; then + return + fi + + local header_name + header_name="$(trim_whitespace "$TOKEN_HEADER")" + if [ -z "$header_name" ]; then + return + fi + + local prefix + prefix="$(trim_whitespace "$TOKEN_PREFIX")" + if [ -n "$prefix" ]; then + local lower_token + local lower_prefix + lower_token="$(printf "%s" "$token" | tr '[:upper:]' '[:lower:]')" + lower_prefix="$(printf "%s" "$prefix" | tr '[:upper:]' '[:lower:]')" + if [[ "$lower_token" != "$lower_prefix"* ]]; then + token="$prefix $token" + fi + fi + + AUTH_HEADER_NAME="$header_name" + AUTH_HEADER_VALUE="$token" +} + +curl_download() { + local url="$1" + local output_path="$2" + local mode="${3:-}" + local curl_args=( + -fsSL + -H "User-Agent: mcp install wizard" + ) + + if [ "$mode" = "json" ]; then + curl_args+=(-H "Accept: application/json") + fi + if [ -n "$AUTH_HEADER_NAME" ] && [ -n "$AUTH_HEADER_VALUE" ]; then + curl_args+=(-H "${AUTH_HEADER_NAME}: ${AUTH_HEADER_VALUE}") + fi + + curl "${curl_args[@]}" "$url" -o "$output_path" +} + +resolve_url_reference() { + local value="$1" + local base="$2" + + if printf "%s" "$value" | grep -Eq '^https?://'; then + printf "%s" "$value" + return + fi + + local origin + origin="$(printf "%s" "$base" | sed -E 's#^(https?://[^/]+).*$#\1#')" + + if [ -z "$origin" ]; then + printf "%s" "$value" + return + fi + + if [ "${value#/}" != "$value" ]; then + printf "%s%s" "$origin" "$value" + return + fi + + local base_no_query + base_no_query="$(printf "%s" "$base" | sed 's/[?#].*$//')" + local base_dir="${base_no_query%/*}" + printf "%s/%s" "$base_dir" "$value" +} + +resolve_asset_url_from_release() { + local release_json_path="$1" + local asset_name="$2" + jq -r --arg asset "$asset_name" ' + ( + .assets.links[]? | select(.name == $asset) | (.direct_asset_url // .browser_download_url // .url // empty) + ), + ( + .assets[]? | select(.name == $asset) | (.direct_asset_url // .browser_download_url // .url // empty) + ) + | select(. != null and . != "") + ' "$release_json_path" | head -n 1 +} + +release_assets_preview() { + local release_json_path="$1" + jq -r ' + [ + (.assets.links[]?.name), + (.assets[]?.name) + ] + | map(select(. != null and . != "")) + | unique + | .[:8] + | join(", ") + ' "$release_json_path" +} + +parse_checksum_for_asset() { + local checksum_file="$1" + local asset_name="$2" + awk -v asset="$asset_name" ' + function is_hex(value) { + return value ~ /^[0-9A-Fa-f]{64}$/ + } + { + line = $0 + sub(/#.*/, "", line) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", line) + if (line == "") { + next + } + + n = split(line, fields, /[[:space:]]+/) + if (n == 1 && is_hex(fields[1]) && fallback == "") { + fallback = fields[1] + next + } + + if (n >= 2 && is_hex(fields[1])) { + candidate = fields[2] + sub(/^\*/, "", candidate) + sub(/^\.\/+/, "", candidate) + if (candidate == asset) { + print fields[1] + exit + } + } + + if (n >= 2 && is_hex(fields[n])) { + candidate = fields[1] + sub(/^\*/, "", candidate) + sub(/^\.\/+/, "", candidate) + if (candidate == asset) { + print fields[n] + exit + } + } + } + END { + if (fallback != "") { + print fallback + } + } + ' "$checksum_file" +} + +sha256_file() { + local file_path="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file_path" | awk '{print $1}' + return + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file_path" | awk '{print $1}' + return + fi + + ui_error "Aucun outil SHA-256 detecte (sha256sum ou shasum requis)." + exit 1 } resolve_binary_path() { @@ -560,13 +1115,9 @@ resolve_binary_path() { return fi - if command -v go >/dev/null 2>&1; then - local bin_dir - bin_dir="$(go_bin_dir)" - if [ -n "$bin_dir" ] && [ -x "$bin_dir/$BINARY_NAME" ]; then - printf "%s\n" "$bin_dir/$BINARY_NAME" - return - fi + if [ -x "$HOME/.local/bin/$BINARY_NAME" ]; then + printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" + return fi printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" @@ -583,10 +1134,18 @@ ensure_cli() { } install_binary() { + ensure_cli "curl" + ensure_cli "jq" + + load_release_config_from_manifest + resolve_auth_header + + local target_path="$HOME/.local/bin/$BINARY_NAME" if command -v "$BINARY_NAME" >/dev/null 2>&1; then - ui_success "Binaire detecte: $(command -v "$BINARY_NAME")" + target_path="$(command -v "$BINARY_NAME")" + ui_success "Binaire detecte: $target_path" local reinstall - reinstall="$(prompt "Reinstaller via go install ? (y/N)" "N")" + reinstall="$(prompt "Reinstaller depuis la derniere release ? (y/N)" "N")" case "$reinstall" in y|Y|yes|YES) ;; @@ -596,19 +1155,118 @@ install_binary() { esac fi - if ! command -v go >/dev/null 2>&1; then - ui_error "Go n'est pas installe. Installe Go ou choisis une configuration manuelle." + if [ ! -w "$(dirname "$target_path")" ]; then + ui_warn "Pas de droit d'ecriture dans $(dirname "$target_path"), installation dans $HOME/.local/bin." + target_path="$HOME/.local/bin/$BINARY_NAME" + fi + + local goos + goos="$(resolve_goos)" + local goarch + goarch="$(resolve_goarch)" + local asset_name + asset_name="$(resolve_asset_name "$goos" "$goarch")" + local release_url + release_url="$(resolve_latest_release_url)" + + ui_info "Recherche de la derniere release pour $RELEASE_REPOSITORY ($goos/$goarch)..." + local release_json + release_json="$(mktemp)" + if ! curl_download "$release_url" "$release_json" "json"; then + rm -f "$release_json" + ui_error "Impossible de recuperer la release latest depuis $release_url." + if [ "${#TOKEN_ENV_NAMES[@]}" -gt 0 ]; then + ui_info "Si le repo est prive, configure un token via: ${TOKEN_ENV_NAMES[*]}" + fi exit 1 fi - ui_info "Installation du binaire via go install..." - go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest" + local release_tag + release_tag="$(jq -r '.tag_name // empty' "$release_json")" + if [ -z "$release_tag" ]; then + rm -f "$release_json" + ui_error "Reponse release invalide: tag_name manquant." + exit 1 + fi - local bin_dir - bin_dir="$(go_bin_dir)" - if [ -n "$bin_dir" ]; then - ui_success "Binaire installe dans $bin_dir" - ui_info "Ajoute ce dossier au PATH si necessaire." + local asset_url + asset_url="$(resolve_asset_url_from_release "$release_json" "$asset_name")" + if [ -z "$asset_url" ]; then + local preview + preview="$(release_assets_preview "$release_json")" + rm -f "$release_json" + ui_error "Aucun asset \"$asset_name\" dans la release $release_tag." + if [ -n "$preview" ]; then + ui_info "Assets disponibles: $preview" + fi + exit 1 + fi + asset_url="$(resolve_url_reference "$asset_url" "$release_url")" + + ui_info "Telechargement de l'asset $asset_name ($release_tag)..." + local download_path + download_path="$(mktemp)" + if ! curl_download "$asset_url" "$download_path"; then + rm -f "$release_json" "$download_path" + ui_error "Echec du telechargement de l'asset: $asset_url" + exit 1 + fi + + local checksum_asset_name + checksum_asset_name="${CHECKSUM_ASSET_NAME//\{asset\}/$asset_name}" + if [ -z "$checksum_asset_name" ]; then + checksum_asset_name="${asset_name}.sha256" + fi + local checksum_url + checksum_url="$(resolve_asset_url_from_release "$release_json" "$checksum_asset_name")" + if [ -n "$checksum_url" ]; then + checksum_url="$(resolve_url_reference "$checksum_url" "$release_url")" + local checksum_path + checksum_path="$(mktemp)" + if ! curl_download "$checksum_url" "$checksum_path"; then + rm -f "$release_json" "$download_path" "$checksum_path" + ui_error "Echec du telechargement du checksum: $checksum_url" + exit 1 + fi + + local expected_checksum + expected_checksum="$(parse_checksum_for_asset "$checksum_path" "$asset_name")" + if [ -z "$expected_checksum" ]; then + rm -f "$release_json" "$download_path" "$checksum_path" + ui_error "Checksum introuvable dans $checksum_asset_name pour l'asset $asset_name." + exit 1 + fi + + local actual_checksum + actual_checksum="$(sha256_file "$download_path")" + local actual_checksum_lc + actual_checksum_lc="$(printf "%s" "$actual_checksum" | tr '[:upper:]' '[:lower:]')" + local expected_checksum_lc + expected_checksum_lc="$(printf "%s" "$expected_checksum" | tr '[:upper:]' '[:lower:]')" + if [ "$actual_checksum_lc" != "$expected_checksum_lc" ]; then + rm -f "$release_json" "$download_path" "$checksum_path" + ui_error "Checksum invalide pour $asset_name." + exit 1 + fi + rm -f "$checksum_path" + ui_success "Checksum verifie pour $asset_name." + else + if [ "$CHECKSUM_REQUIRED" = "true" ]; then + rm -f "$release_json" "$download_path" + ui_error "Checksum requis mais asset \"$checksum_asset_name\" introuvable." + exit 1 + fi + ui_warn "Checksum non disponible pour $asset_name (verification ignoree)." + fi + + chmod +x "$download_path" + mkdir -p "$(dirname "$target_path")" + mv "$download_path" "$target_path" + rm -f "$release_json" + ui_success "Binaire installe: $target_path" + + if ! printf ":%s:" "$PATH" | grep -Fq ":$(dirname "$target_path"):"; then + ui_info "Ajoute $(dirname "$target_path") au PATH si necessaire." fi } diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 2f59ace..67e3aef 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -127,7 +127,12 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "#!/usr/bin/env bash", `MODULE_PATH="example.com/acme/my-mcp"`, - `go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`, + `DEFAULT_RELEASE_REPOSITORY="org/my-mcp"`, + `load_release_config_from_manifest`, + `resolve_latest_release_url()`, + `curl_download "$release_url" "$release_json" "json"`, + `asset_name="$(resolve_asset_name "$goos" "$goarch")"`, + `Reinstaller depuis la derniere release ? (y/N)`, "MCP Install Wizard", `menu_select() {`, `Utilise ↑/↓ puis Entrée.`, -- 2.45.2 From bba7aacedfce4d3774334db847d8b011aa9e7d72 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 08:30:35 +0200 Subject: [PATCH 32/79] feat(secretstore): add bitwarden CLI backend support --- docs/manifest.md | 2 +- docs/secrets.md | 12 ++ secretstore/bitwarden.go | 345 ++++++++++++++++++++++++++++++++++ secretstore/bitwarden_test.go | 341 +++++++++++++++++++++++++++++++++ secretstore/store.go | 26 ++- 5 files changed, 715 insertions(+), 11 deletions(-) create mode 100644 secretstore/bitwarden.go create mode 100644 secretstore/bitwarden_test.go diff --git a/docs/manifest.md b/docs/manifest.md index 0e3b064..5868c40 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -59,7 +59,7 @@ Champs supportés : - `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...). - `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`). +- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`, `bitwarden-cli`). - `[profiles].default` : profil recommandé par défaut. - `[profiles].known` : profils connus du projet. - `[bootstrap].description` : description CLI utilisée par le bootstrap. diff --git a/docs/secrets.md b/docs/secrets.md index 522d48d..9fcbf66 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -6,6 +6,7 @@ Le package `secretstore` supporte plusieurs politiques de backend : - `kwallet-only` : impose KWallet et retourne une erreur explicite si KWallet n'est pas disponible - `keyring-any` : impose l'utilisation d'un backend keyring disponible - `env-only` : lecture seule depuis les variables d'environnement +- `bitwarden-cli` : utilise le CLI Bitwarden (`bw`) comme backend de vault Backends keyring typiques : @@ -60,6 +61,17 @@ store, err := secretstore.Open(secretstore.Options{ }) ``` +Pour imposer Bitwarden via son CLI : + +```go +store, err := secretstore.Open(secretstore.Options{ + ServiceName: "email-mcp", + BackendPolicy: secretstore.BackendBitwardenCLI, + // Optionnel si `bw` n'est pas dans le PATH : + // BitwardenCommand: "/usr/local/bin/bw", +}) +``` + Pour stocker un secret structuré en JSON : ```go diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go new file mode 100644 index 0000000..55497c7 --- /dev/null +++ b/secretstore/bitwarden.go @@ -0,0 +1,345 @@ +package secretstore + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" +) + +const ( + defaultBitwardenCommand = "bw" + bitwardenSecretFieldName = "mcp-secret" +) + +type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error) + +var runBitwardenCLI bitwardenRunner = executeBitwardenCLI + +type bitwardenStore struct { + command string + serviceName string +} + +type bitwardenListItem struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func newBitwardenStore(options Options, policy BackendPolicy, serviceName string) (Store, error) { + command := strings.TrimSpace(options.BitwardenCommand) + if command == "" { + command = defaultBitwardenCommand + } + + store := &bitwardenStore{ + command: command, + serviceName: serviceName, + } + + if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return nil, fmt.Errorf( + "secret backend policy %q requires bitwarden CLI command %q in PATH: %w", + policy, + command, + ErrBackendUnavailable, + ) + } + + return nil, fmt.Errorf( + "secret backend policy %q cannot verify bitwarden CLI command %q: %w", + policy, + command, + errors.Join(ErrBackendUnavailable, err), + ) + } + + return store, nil +} + +func (s *bitwardenStore) SetSecret(name, label, secret string) error { + secretName := s.scopedName(name) + item, err := s.findItem(secretName) + switch { + case errors.Is(err, ErrNotFound): + template, err := s.itemTemplate() + if err != nil { + return err + } + + setBitwardenSecretPayload(template, secretName, label, secret) + encoded, err := s.encodePayload(template) + if err != nil { + return err + } + + if _, err := s.execute( + fmt.Sprintf("create bitwarden item for secret %q", name), + nil, + "create", + "item", + encoded, + ); err != nil { + return err + } + return nil + case err != nil: + return err + } + + payload, err := s.itemByID(item.ID) + if err != nil { + return err + } + + setBitwardenSecretPayload(payload, secretName, label, secret) + encoded, err := s.encodePayload(payload) + if err != nil { + return err + } + + if _, err := s.execute( + fmt.Sprintf("update bitwarden item for secret %q", name), + nil, + "edit", + "item", + item.ID, + encoded, + ); err != nil { + return err + } + + return nil +} + +func (s *bitwardenStore) GetSecret(name string) (string, error) { + secretName := s.scopedName(name) + item, err := s.findItem(secretName) + if err != nil { + return "", err + } + + payload, err := s.itemByID(item.ID) + if err != nil { + return "", err + } + + secret, ok := readBitwardenSecret(payload) + if !ok { + return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName) + } + + return secret, nil +} + +func (s *bitwardenStore) DeleteSecret(name string) error { + secretName := s.scopedName(name) + item, err := s.findItem(secretName) + if errors.Is(err, ErrNotFound) { + return nil + } + if err != nil { + return err + } + + if _, err := s.execute( + fmt.Sprintf("delete bitwarden item for secret %q", name), + nil, + "delete", + "item", + item.ID, + ); err != nil { + return err + } + + return nil +} + +func (s *bitwardenStore) scopedName(name string) string { + return fmt.Sprintf("%s/%s", s.serviceName, name) +} + +func (s *bitwardenStore) findItem(secretName string) (bitwardenListItem, error) { + output, err := s.execute( + fmt.Sprintf("list bitwarden items for secret %q", secretName), + nil, + "list", + "items", + "--search", + secretName, + ) + if err != nil { + return bitwardenListItem{}, err + } + + var items []bitwardenListItem + if err := json.Unmarshal(output, &items); err != nil { + return bitwardenListItem{}, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err) + } + + matches := make([]bitwardenListItem, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item.Name) != secretName { + continue + } + if strings.TrimSpace(item.ID) == "" { + continue + } + matches = append(matches, item) + } + + switch len(matches) { + case 0: + return bitwardenListItem{}, ErrNotFound + case 1: + return matches[0], nil + default: + return bitwardenListItem{}, fmt.Errorf( + "multiple bitwarden items match secret %q for service %q", + secretName, + s.serviceName, + ) + } +} + +func (s *bitwardenStore) itemTemplate() (map[string]any, error) { + output, err := s.execute("load bitwarden item template", nil, "get", "template", "item") + if err != nil { + return nil, err + } + + var payload map[string]any + if err := json.Unmarshal(output, &payload); err != nil { + return nil, fmt.Errorf("decode bitwarden item template: %w", err) + } + + return payload, nil +} + +func (s *bitwardenStore) itemByID(id string) (map[string]any, error) { + trimmedID := strings.TrimSpace(id) + if trimmedID == "" { + return nil, errors.New("bitwarden item id must not be empty") + } + + output, err := s.execute( + fmt.Sprintf("read bitwarden item %q", trimmedID), + nil, + "get", + "item", + trimmedID, + ) + if err != nil { + return nil, err + } + + var payload map[string]any + if err := json.Unmarshal(output, &payload); err != nil { + return nil, fmt.Errorf("decode bitwarden item %q: %w", trimmedID, err) + } + + return payload, nil +} + +func (s *bitwardenStore) encodePayload(payload map[string]any) (string, error) { + raw, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("encode bitwarden payload: %w", err) + } + + output, err := s.execute("encode bitwarden payload", raw, "encode") + if err != nil { + return "", err + } + + encoded := strings.TrimSpace(string(output)) + if encoded == "" { + return "", errors.New("bitwarden CLI returned an empty encoded payload") + } + + return encoded, nil +} + +func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) { + output, err := runBitwardenCLI(s.command, stdin, args...) + if err != nil { + return nil, fmt.Errorf("%s: %w", operation, err) + } + + return output, nil +} + +func setBitwardenSecretPayload(payload map[string]any, secretName, label, secret string) { + payload["type"] = 2 + payload["name"] = secretName + payload["notes"] = strings.TrimSpace(label) + payload["secureNote"] = map[string]any{"type": 0} + payload["fields"] = []map[string]any{ + { + "name": bitwardenSecretFieldName, + "value": secret, + "type": 1, + }, + } +} + +func readBitwardenSecret(payload map[string]any) (string, bool) { + rawFields, ok := payload["fields"] + if !ok { + return "", false + } + + fields, ok := rawFields.([]any) + if !ok { + return "", false + } + + for _, rawField := range fields { + field, ok := rawField.(map[string]any) + if !ok { + continue + } + + name, _ := field["name"].(string) + if strings.TrimSpace(name) != bitwardenSecretFieldName { + continue + } + + value, ok := field["value"].(string) + if !ok { + return "", false + } + + return value, true + } + + return "", false +} + +func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { + cmd := exec.Command(command, args...) + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + if detail == "" { + return nil, err + } + return nil, fmt.Errorf("%w: %s", err, detail) + } + + return stdout.Bytes(), nil +} diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go new file mode 100644 index 0000000..8f6e2f0 --- /dev/null +++ b/secretstore/bitwarden_test.go @@ -0,0 +1,341 @@ +package secretstore + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) { + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if _, ok := store.(*bitwardenStore); !ok { + t.Fatalf("store type = %T, want *bitwardenStore", store) + } + if !fakeCLI.versionChecked { + t.Fatal("expected bitwarden CLI version check") + } +} + +func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) { + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "graylog-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret = %q, want secret-v1", value) + } + + if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil { + t.Fatalf("SetSecret (update) returned error: %v", err) + } + + value, err = store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret (updated) returned error: %v", err) + } + if value != "secret-v2" { + t.Fatalf("GetSecret (updated) = %q, want secret-v2", value) + } + + if err := store.DeleteSecret("api-token"); err != nil { + t.Fatalf("DeleteSecret returned error: %v", err) + } + + _, err = store.GetSecret("api-token") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err) + } +} + +func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + return nil, &exec.Error{Name: command, Err: exec.ErrNotFound} + }) + + _, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBackendUnavailable) { + t.Fatalf("error = %v, want ErrBackendUnavailable", err) + } +} + +func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := OpenFromManifest(OpenFromManifestOptions{ + ServiceName: "email-mcp", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)}, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("OpenFromManifest returned error: %v", err) + } + + if err := store.SetSecret("smtp-password", "SMTP password", "super-secret"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + + value, err := store.GetSecret("smtp-password") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "super-secret" { + t.Fatalf("GetSecret = %q, want super-secret", value) + } +} + +func withBitwardenRunner( + t *testing.T, + runner func(command string, stdin []byte, args ...string) ([]byte, error), +) { + t.Helper() + + previous := runBitwardenCLI + runBitwardenCLI = runner + t.Cleanup(func() { + runBitwardenCLI = previous + }) +} + +type fakeBitwardenCLI struct { + command string + itemsByID map[string]fakeBitwardenItem + nextID int + versionChecked bool +} + +type fakeBitwardenItem struct { + ID string + Name string + Notes string + Secret string +} + +func newFakeBitwardenCLI(command string) *fakeBitwardenCLI { + return &fakeBitwardenCLI{ + command: strings.TrimSpace(command), + itemsByID: map[string]fakeBitwardenItem{}, + nextID: 1, + } +} + +func (f *fakeBitwardenCLI) run(command string, stdin []byte, args ...string) ([]byte, error) { + if strings.TrimSpace(command) != f.command { + return nil, fmt.Errorf("unexpected command %q", command) + } + if len(args) == 0 { + return nil, errors.New("missing bitwarden CLI arguments") + } + + if len(args) == 1 && args[0] == "--version" { + f.versionChecked = true + return []byte("2026.1.0\n"), nil + } + + switch { + case len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search": + return f.handleListItems(args[3]) + case len(args) == 3 && args[0] == "get" && args[1] == "item": + return f.handleGetItem(args[2]) + case len(args) == 3 && args[0] == "get" && args[1] == "template" && args[2] == "item": + return []byte(`{"type":2,"name":"","notes":"","secureNote":{"type":0},"fields":[]}`), nil + case len(args) == 1 && args[0] == "encode": + return []byte(base64.StdEncoding.EncodeToString(stdin)), nil + case len(args) == 3 && args[0] == "create" && args[1] == "item": + return f.handleCreateItem(args[2]) + case len(args) == 4 && args[0] == "edit" && args[1] == "item": + return f.handleEditItem(args[2], args[3]) + case len(args) == 3 && args[0] == "delete" && args[1] == "item": + delete(f.itemsByID, strings.TrimSpace(args[2])) + return []byte(`{"success":true}`), nil + default: + return nil, fmt.Errorf("unsupported bitwarden CLI invocation: %v", args) + } +} + +func (f *fakeBitwardenCLI) handleListItems(search string) ([]byte, error) { + needle := strings.TrimSpace(search) + items := make([]map[string]any, 0) + for _, item := range f.itemsByID { + if !strings.Contains(item.Name, needle) { + continue + } + items = append(items, map[string]any{ + "id": item.ID, + "name": item.Name, + "notes": item.Notes, + "fields": []map[string]any{ + {"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1}, + }, + }) + } + + payload, err := json.Marshal(items) + if err != nil { + return nil, err + } + return payload, nil +} + +func (f *fakeBitwardenCLI) handleGetItem(id string) ([]byte, error) { + item, ok := f.itemsByID[strings.TrimSpace(id)] + if !ok { + return nil, errors.New("item not found") + } + + payload, err := json.Marshal(map[string]any{ + "id": item.ID, + "name": item.Name, + "notes": item.Notes, + "fields": []map[string]any{ + {"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1}, + }, + "secureNote": map[string]any{"type": 0}, + }) + if err != nil { + return nil, err + } + + return payload, nil +} + +func (f *fakeBitwardenCLI) handleCreateItem(encoded string) ([]byte, error) { + payload, err := decodeBitwardenPayload(encoded) + if err != nil { + return nil, err + } + + item := fakeBitwardenItem{ + ID: fmt.Sprintf("item-%d", f.nextID), + Name: readString(payload, "name"), + Notes: readString(payload, "notes"), + Secret: readFakeBitwardenSecret(payload), + } + f.nextID++ + f.itemsByID[item.ID] = item + + payload["id"] = item.ID + encodedPayload, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return encodedPayload, nil +} + +func (f *fakeBitwardenCLI) handleEditItem(id, encoded string) ([]byte, error) { + trimmedID := strings.TrimSpace(id) + if _, ok := f.itemsByID[trimmedID]; !ok { + return nil, errors.New("item not found") + } + + payload, err := decodeBitwardenPayload(encoded) + if err != nil { + return nil, err + } + + item := fakeBitwardenItem{ + ID: trimmedID, + Name: readString(payload, "name"), + Notes: readString(payload, "notes"), + Secret: readFakeBitwardenSecret(payload), + } + f.itemsByID[trimmedID] = item + + payload["id"] = trimmedID + encodedPayload, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return encodedPayload, nil +} + +func decodeBitwardenPayload(encoded string) (map[string]any, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return nil, fmt.Errorf("decode encoded payload: %w", err) + } + + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("decode payload JSON: %w", err) + } + + return payload, nil +} + +func readFakeBitwardenSecret(payload map[string]any) string { + rawFields, ok := payload["fields"] + if !ok { + return "" + } + + fields, ok := rawFields.([]any) + if !ok { + return "" + } + + for _, rawField := range fields { + field, ok := rawField.(map[string]any) + if !ok { + continue + } + + name := strings.TrimSpace(readString(field, "name")) + if name != bitwardenSecretFieldName { + continue + } + + return readString(field, "value") + } + + return "" +} + +func readString(payload map[string]any, key string) string { + value, _ := payload[key].(string) + return strings.TrimSpace(value) +} diff --git a/secretstore/store.go b/secretstore/store.go index 1cd61d1..d957562 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -20,18 +20,20 @@ var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy") type BackendPolicy string const ( - BackendAuto BackendPolicy = "auto" - BackendKWalletOnly BackendPolicy = "kwallet-only" - BackendKeyringAny BackendPolicy = "keyring-any" - BackendEnvOnly BackendPolicy = "env-only" + BackendAuto BackendPolicy = "auto" + BackendKWalletOnly BackendPolicy = "kwallet-only" + BackendKeyringAny BackendPolicy = "keyring-any" + BackendEnvOnly BackendPolicy = "env-only" + BackendBitwardenCLI BackendPolicy = "bitwarden-cli" ) type Options struct { - ServiceName string - BackendPolicy BackendPolicy - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string + ServiceName string + BackendPolicy BackendPolicy + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string } type Store interface { @@ -92,6 +94,10 @@ func Open(options Options) (Store, error) { return nil, errors.New("service name must not be empty") } + if policy == BackendBitwardenCLI { + return newBitwardenStore(options, policy, serviceName) + } + available := availableKeyringPolicy() allowed, err := allowedBackends(policy, available) if err != nil { @@ -263,7 +269,7 @@ func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) { } switch trimmed { - case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly: + case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly, BackendBitwardenCLI: return trimmed, nil default: return "", invalidBackendPolicyError(trimmed) -- 2.45.2 From 7072cb2038ea38741a899abf88f5d648ddcd4df0 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 09:39:05 +0200 Subject: [PATCH 33/79] feat(secretstore): harden bitwarden readiness and secret verification --- cli/doctor.go | 54 ++++++ cli/doctor_test.go | 56 ++++++ docs/cli-helpers.md | 12 ++ docs/secrets.md | 34 ++++ scaffold/scaffold.go | 87 ++++++++-- secretstore/bitwarden.go | 246 +++++++++++++++++++++++--- secretstore/bitwarden_test.go | 317 ++++++++++++++++++++++++++++++---- secretstore/store.go | 37 ++++ secretstore/store_test.go | 85 +++++++++ 9 files changed, 859 insertions(+), 69 deletions(-) diff --git a/cli/doctor.go b/cli/doctor.go index 27b15a8..ec859e7 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -60,6 +60,13 @@ type DoctorOptions struct { ExtraChecks []DoctorCheck } +type BitwardenDoctorOptions struct { + Command string + LookupEnv func(string) (string, bool) +} + +var checkBitwardenReady = secretstore.EnsureBitwardenReady + func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport { checks := make([]DoctorCheck, 0, 5+len(options.ExtraChecks)) @@ -220,6 +227,53 @@ func SecretStoreAvailabilityCheck(factory func() (secretstore.Store, error)) Doc } } +func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck { + return func(context.Context) DoctorResult { + err := checkBitwardenReady(secretstore.Options{ + BitwardenCommand: strings.TrimSpace(options.Command), + LookupEnv: options.LookupEnv, + }) + if err == nil { + return DoctorResult{ + Name: "bitwarden", + Status: DoctorStatusOK, + Summary: "bitwarden CLI is ready", + } + } + + switch { + case errors.Is(err, secretstore.ErrBWNotLoggedIn): + return DoctorResult{ + Name: "bitwarden", + Status: DoctorStatusFail, + Summary: "bitwarden login is required", + Detail: err.Error(), + } + case errors.Is(err, secretstore.ErrBWLocked): + return DoctorResult{ + Name: "bitwarden", + Status: DoctorStatusFail, + Summary: "bitwarden vault is locked or BW_SESSION is missing", + Detail: err.Error(), + } + case errors.Is(err, secretstore.ErrBWUnavailable): + return DoctorResult{ + Name: "bitwarden", + Status: DoctorStatusFail, + Summary: "bitwarden CLI is unavailable", + Detail: err.Error(), + } + default: + return DoctorResult{ + Name: "bitwarden", + Status: DoctorStatusFail, + Summary: "bitwarden readiness check failed", + Detail: err.Error(), + } + } + } +} + func RequiredSecretsCheck(factory func() (secretstore.Store, error), required []DoctorSecret) DoctorCheck { return func(context.Context) DoctorResult { store, err := factory() diff --git a/cli/doctor_test.go b/cli/doctor_test.go index 04aeb99..698cd74 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "os" "path/filepath" "strings" @@ -197,6 +198,61 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) { } } +func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) { + prev := checkBitwardenReady + t.Cleanup(func() { + checkBitwardenReady = prev + }) + + t.Run("not logged in", func(t *testing.T) { + checkBitwardenReady = func(options secretstore.Options) error { + return fmt.Errorf("%w: run `bw login`", secretstore.ErrBWNotLoggedIn) + } + + result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background()) + if result.Status != DoctorStatusFail { + t.Fatalf("status = %q, want fail", result.Status) + } + if result.Summary != "bitwarden login is required" { + t.Fatalf("summary = %q", result.Summary) + } + if !strings.Contains(result.Detail, "bw login") { + t.Fatalf("detail = %q, want login remediation", result.Detail) + } + }) + + t.Run("locked", func(t *testing.T) { + checkBitwardenReady = func(options secretstore.Options) error { + return fmt.Errorf("%w: run `bw unlock --raw`", secretstore.ErrBWLocked) + } + + result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background()) + if result.Status != DoctorStatusFail { + t.Fatalf("status = %q, want fail", result.Status) + } + if result.Summary != "bitwarden vault is locked or BW_SESSION is missing" { + t.Fatalf("summary = %q", result.Summary) + } + if !strings.Contains(result.Detail, "bw unlock --raw") { + t.Fatalf("detail = %q, want unlock remediation", result.Detail) + } + }) + + t.Run("ready", func(t *testing.T) { + checkBitwardenReady = func(options secretstore.Options) error { + return nil + } + + result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background()) + if result.Status != DoctorStatusOK { + t.Fatalf("status = %q, want ok", result.Status) + } + if result.Summary != "bitwarden CLI is ready" { + t.Fatalf("summary = %q", result.Summary) + } + }) +} + func TestRequiredResolvedFieldsCheckReportsSources(t *testing.T) { check := RequiredResolvedFieldsCheck(ResolveOptions{ Fields: []FieldSpec{ diff --git a/docs/cli-helpers.md b/docs/cli-helpers.md index 4077f15..69d97b3 100644 --- a/docs/cli-helpers.md +++ b/docs/cli-helpers.md @@ -158,6 +158,18 @@ if report.HasFailures() { } ``` +Pour une policy `bitwarden-cli`, tu peux ajouter un check dédié avec remédiations : + +```go +report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ + ExtraChecks: []cli.DoctorCheck{ + cli.BitwardenReadyCheck(cli.BitwardenDoctorOptions{ + LookupEnv: os.LookupEnv, + }), + }, +}) +``` + Pour éviter des checks custom répétitifs sur les profils, `doctor` expose aussi un helper basé sur `FieldSpec` : ```go diff --git a/docs/secrets.md b/docs/secrets.md index 9fcbf66..9d9e683 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -72,6 +72,25 @@ store, err := secretstore.Open(secretstore.Options{ }) ``` +Pour vérifier explicitement que Bitwarden est prêt (login + unlock + `BW_SESSION`) : + +```go +if err := secretstore.EnsureBitwardenReady(secretstore.Options{ + BitwardenCommand: "bw", + LookupEnv: os.LookupEnv, +}); err != nil { + switch { + case errors.Is(err, secretstore.ErrBWNotLoggedIn): + // guider vers `bw login` + case errors.Is(err, secretstore.ErrBWLocked): + // guider vers `bw unlock --raw` puis export BW_SESSION + default: + // indisponibilité CLI/réseau + } + return err +} +``` + Pour stocker un secret structuré en JSON : ```go @@ -97,5 +116,20 @@ if err != nil { _ = creds ``` +Pour écrire puis confirmer immédiatement une relecture : + +```go +if err := secretstore.SetSecretVerified(store, "api-token", "My MCP API token", token); err != nil { + return err +} +``` + +Pour connaître le backend effectif utilisé : + +```go +effective := secretstore.EffectiveBackendPolicy(store) +fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any... +``` + En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`. Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`. diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 135f4af..a94b97c 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -1677,6 +1677,10 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { stdout = os.Stdout } + if _, err := r.openSecretStore(); err != nil { + return fmt.Errorf("secret backend is not ready: %w", err) + } + cfg, _, err := r.ConfigStore.LoadDefault() if err != nil { return err @@ -1684,7 +1688,14 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { profileName := r.resolveProfileName(cfg.CurrentProfile) profile := cfg.Profiles[profileName] - storedToken, _ := r.readToken() + storedToken, err := r.readToken() + switch { + case err == nil: + case errors.Is(err, secretstore.ErrNotFound): + storedToken = "" + default: + return err + } result, err := cli.RunSetup(cli.SetupOptions{ Stdin: stdin, @@ -1726,16 +1737,34 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { return err } - if err := store.SetSecret(r.SecretName, "API token", tokenValue.String); err != nil { + if err := secretstore.SetSecretVerified(store, r.SecretName, "API token", tokenValue.String); err != nil { if errors.Is(err, secretstore.ErrReadOnly) { - fmt.Fprintf(stdout, "Secret store en lecture seule, exporte %s pour fournir le token.\n", r.TokenEnv) + return fmt.Errorf( + "secret store is read-only, export %s and retry setup", + r.TokenEnv, + ) } else { return err } } } - _, err = fmt.Fprintf(stdout, "Configuration sauvegardée pour le profil %q.\n", profileName) + verifiedToken, err := r.readToken() + if err != nil { + if errors.Is(err, secretstore.ErrNotFound) { + return fmt.Errorf( + "secret %q is not readable after setup, export %s and retry", + r.SecretName, + r.TokenEnv, + ) + } + return err + } + if strings.TrimSpace(verifiedToken) == "" { + return fmt.Errorf("secret %q is empty after setup", r.SecretName) + } + + _, err = fmt.Fprintf(stdout, "Configuration saved for profile %q. Secret readability confirmed.\n", profileName) return err } @@ -1789,6 +1818,12 @@ func (r Runtime) runConfigShow(_ context.Context, inv bootstrap.Invocation) erro if _, err := fmt.Fprintf(stdout, "Config: %s\n", path); err != nil { return err } + if _, err := fmt.Fprintf(stdout, "Manifest source: %s\n", r.manifestSourceLabel()); err != nil { + return err + } + if _, err := fmt.Fprintf(stdout, "Secret backend policy (active): %s\n", r.activeBackendPolicy()); err != nil { + return err + } _, err = fmt.Fprintf(stdout, "%s\n", payload) return err } @@ -1807,6 +1842,9 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er }, SecretStoreFactory: r.openSecretStore, ManifestCheck: r.manifestDoctorCheck(), + ExtraChecks: []cli.DoctorCheck{ + r.bitwardenDoctorCheck(), + }, }) if err := cli.RenderDoctorReport(stdout, report); err != nil { @@ -1835,14 +1873,9 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error } func (r Runtime) openSecretStore() (secretstore.Store, error) { - backendPolicy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy)) - if backendPolicy == "" { - backendPolicy = secretstore.BackendAuto - } - return secretstore.Open(secretstore.Options{ - ServiceName: r.BinaryName, - BackendPolicy: backendPolicy, + ServiceName: r.BinaryName, + BackendPolicy: r.activeBackendPolicy(), LookupEnv: func(name string) (string, bool) { if name == r.SecretName { return os.LookupEnv(r.TokenEnv) @@ -1852,6 +1885,22 @@ func (r Runtime) openSecretStore() (secretstore.Store, error) { }) } +func (r Runtime) activeBackendPolicy() secretstore.BackendPolicy { + policy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy)) + if policy == "" { + return secretstore.BackendAuto + } + return policy +} + +func (r Runtime) manifestSourceLabel() string { + source := strings.TrimSpace(r.ManifestSource) + if source == "" { + return "embedded defaults" + } + return source +} + func (r Runtime) readToken() (string, error) { store, err := r.openSecretStore() if err != nil { @@ -1881,12 +1930,12 @@ func firstNonEmpty(values ...string) string { func (r Runtime) manifestDoctorCheck() cli.DoctorCheck { return func(context.Context) cli.DoctorResult { - source := strings.TrimSpace(r.ManifestSource) - if source == "" { + if strings.TrimSpace(r.ManifestSource) == "" { return cli.DoctorResult{ Name: "manifest", Status: cli.DoctorStatusWarn, Summary: "manifest is missing, using built-in defaults", + Detail: fmt.Sprintf("source=%s policy=%s", r.manifestSourceLabel(), r.activeBackendPolicy()), } } @@ -1894,10 +1943,20 @@ func (r Runtime) manifestDoctorCheck() cli.DoctorCheck { Name: "manifest", Status: cli.DoctorStatusOK, Summary: "manifest is valid", - Detail: source, + Detail: fmt.Sprintf("source=%s policy=%s", r.manifestSourceLabel(), r.activeBackendPolicy()), } } } + +func (r Runtime) bitwardenDoctorCheck() cli.DoctorCheck { + if r.activeBackendPolicy() != secretstore.BackendBitwardenCLI { + return nil + } + + return cli.BitwardenReadyCheck(cli.BitwardenDoctorOptions{ + LookupEnv: os.LookupEnv, + }) +} ` const manifestTemplate = `binary_name = "{{.BinaryName}}" diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 55497c7..2a29cee 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -5,13 +5,17 @@ import ( "encoding/json" "errors" "fmt" + "os" "os/exec" "strings" ) const ( - defaultBitwardenCommand = "bw" - bitwardenSecretFieldName = "mcp-secret" + defaultBitwardenCommand = "bw" + bitwardenSessionEnvName = "BW_SESSION" + bitwardenSecretFieldName = "mcp-secret" + bitwardenServiceFieldName = "mcp-service" + bitwardenSecretNameFieldName = "mcp-secret-name" ) type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error) @@ -28,6 +32,10 @@ type bitwardenListItem struct { Name string `json:"name"` } +type bitwardenStatusOutput struct { + Status string `json:"status"` +} + func newBitwardenStore(options Options, policy BackendPolicy, serviceName string) (Store, error) { command := strings.TrimSpace(options.BitwardenCommand) if command == "" { @@ -57,12 +65,79 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string ) } + if err := EnsureBitwardenReady(Options{ + BitwardenCommand: command, + LookupEnv: options.LookupEnv, + }); err != nil { + return nil, fmt.Errorf( + "secret backend policy %q cannot use bitwarden CLI command %q right now: %w", + policy, + command, + errors.Join(ErrBackendUnavailable, err), + ) + } + return store, nil } +func EnsureBitwardenReady(options Options) error { + command := strings.TrimSpace(options.BitwardenCommand) + if command == "" { + command = defaultBitwardenCommand + } + + lookupEnv := options.LookupEnv + if lookupEnv == nil { + lookupEnv = os.LookupEnv + } + + output, err := runBitwardenCLI(command, nil, "status") + if err != nil { + return fmt.Errorf("check bitwarden CLI status: %w", err) + } + + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return fmt.Errorf("%w: bitwarden CLI returned an empty status", ErrBWUnavailable) + } + + var status bitwardenStatusOutput + if err := json.Unmarshal([]byte(trimmed), &status); err != nil { + return fmt.Errorf("decode bitwarden CLI status: %w", errors.Join(ErrBWUnavailable, err)) + } + + switch strings.ToLower(strings.TrimSpace(status.Status)) { + case "unauthenticated": + return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn) + case "locked": + return fmt.Errorf( + "%w: run `export %s=\"$(bw unlock --raw)\"` then retry", + ErrBWLocked, + bitwardenSessionEnvName, + ) + case "unlocked": + session, ok := lookupEnv(bitwardenSessionEnvName) + if !ok || strings.TrimSpace(session) == "" { + return fmt.Errorf( + "%w: environment variable %q is missing; run `export %s=\"$(bw unlock --raw)\"` then retry", + ErrBWLocked, + bitwardenSessionEnvName, + bitwardenSessionEnvName, + ) + } + return nil + default: + return fmt.Errorf( + "%w: unsupported bitwarden status %q", + ErrBWUnavailable, + strings.TrimSpace(status.Status), + ) + } +} + func (s *bitwardenStore) SetSecret(name, label, secret string) error { secretName := s.scopedName(name) - item, err := s.findItem(secretName) + item, err := s.findItem(secretName, name) switch { case errors.Is(err, ErrNotFound): template, err := s.itemTemplate() @@ -70,7 +145,7 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { return err } - setBitwardenSecretPayload(template, secretName, label, secret) + setBitwardenSecretPayload(template, s.serviceName, name, secretName, label, secret) encoded, err := s.encodePayload(template) if err != nil { return err @@ -95,7 +170,7 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { return err } - setBitwardenSecretPayload(payload, secretName, label, secret) + setBitwardenSecretPayload(payload, s.serviceName, name, secretName, label, secret) encoded, err := s.encodePayload(payload) if err != nil { return err @@ -117,7 +192,7 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { func (s *bitwardenStore) GetSecret(name string) (string, error) { secretName := s.scopedName(name) - item, err := s.findItem(secretName) + item, err := s.findItem(secretName, name) if err != nil { return "", err } @@ -137,7 +212,7 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) { func (s *bitwardenStore) DeleteSecret(name string) error { secretName := s.scopedName(name) - item, err := s.findItem(secretName) + item, err := s.findItem(secretName, name) if errors.Is(err, ErrNotFound) { return nil } @@ -162,7 +237,7 @@ func (s *bitwardenStore) scopedName(name string) string { return fmt.Sprintf("%s/%s", s.serviceName, name) } -func (s *bitwardenStore) findItem(secretName string) (bitwardenListItem, error) { +func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenListItem, error) { output, err := s.execute( fmt.Sprintf("list bitwarden items for secret %q", secretName), nil, @@ -174,6 +249,9 @@ func (s *bitwardenStore) findItem(secretName string) (bitwardenListItem, error) if err != nil { return bitwardenListItem{}, err } + if strings.TrimSpace(string(output)) == "" { + return bitwardenListItem{}, ErrNotFound + } var items []bitwardenListItem if err := json.Unmarshal(output, &items); err != nil { @@ -191,14 +269,44 @@ func (s *bitwardenStore) findItem(secretName string) (bitwardenListItem, error) matches = append(matches, item) } - switch len(matches) { - case 0: + if len(matches) == 0 { return bitwardenListItem{}, ErrNotFound + } + + markedMatches := make([]bitwardenListItem, 0, len(matches)) + legacyMatches := make([]bitwardenListItem, 0, len(matches)) + for _, item := range matches { + payload, err := s.itemByID(item.ID) + if err != nil { + return bitwardenListItem{}, err + } + + if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) { + markedMatches = append(markedMatches, item) + continue + } + legacyMatches = append(legacyMatches, item) + } + + switch len(markedMatches) { + case 0: + switch len(legacyMatches) { + case 0: + return bitwardenListItem{}, ErrNotFound + case 1: + return legacyMatches[0], nil + default: + return bitwardenListItem{}, fmt.Errorf( + "multiple legacy bitwarden items match secret %q for service %q", + secretName, + s.serviceName, + ) + } case 1: - return matches[0], nil + return markedMatches[0], nil default: return bitwardenListItem{}, fmt.Errorf( - "multiple bitwarden items match secret %q for service %q", + "multiple bitwarden items share marker for secret %q and service %q", secretName, s.serviceName, ) @@ -272,7 +380,7 @@ func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) return output, nil } -func setBitwardenSecretPayload(payload map[string]any, secretName, label, secret string) { +func setBitwardenSecretPayload(payload map[string]any, serviceName, rawSecretName, secretName, label, secret string) { payload["type"] = 2 payload["name"] = secretName payload["notes"] = strings.TrimSpace(label) @@ -283,10 +391,24 @@ func setBitwardenSecretPayload(payload map[string]any, secretName, label, secret "value": secret, "type": 1, }, + { + "name": bitwardenServiceFieldName, + "value": strings.TrimSpace(serviceName), + "type": 0, + }, + { + "name": bitwardenSecretNameFieldName, + "value": strings.TrimSpace(rawSecretName), + "type": 0, + }, } } func readBitwardenSecret(payload map[string]any) (string, bool) { + return readBitwardenField(payload, bitwardenSecretFieldName) +} + +func readBitwardenField(payload map[string]any, fieldName string) (string, bool) { rawFields, ok := payload["fields"] if !ok { return "", false @@ -304,7 +426,7 @@ func readBitwardenSecret(payload map[string]any) (string, bool) { } name, _ := field["name"].(string) - if strings.TrimSpace(name) != bitwardenSecretFieldName { + if strings.TrimSpace(name) != strings.TrimSpace(fieldName) { continue } @@ -319,6 +441,20 @@ func readBitwardenSecret(payload map[string]any) (string, bool) { return "", false } +func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName string) bool { + markedService, ok := readBitwardenField(payload, bitwardenServiceFieldName) + if !ok { + return false + } + markedSecretName, ok := readBitwardenField(payload, bitwardenSecretNameFieldName) + if !ok { + return false + } + + return strings.TrimSpace(markedService) == strings.TrimSpace(serviceName) && + strings.TrimSpace(markedSecretName) == strings.TrimSpace(secretName) +} + func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { cmd := exec.Command(command, args...) if stdin != nil { @@ -331,15 +467,81 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - detail := strings.TrimSpace(stderr.String()) - if detail == "" { - detail = strings.TrimSpace(stdout.String()) - } - if detail == "" { - return nil, err - } - return nil, fmt.Errorf("%w: %s", err, detail) + return nil, normalizeBitwardenExecutionError(err, stderr.String(), stdout.String()) } return stdout.Bytes(), nil } + +func normalizeBitwardenExecutionError(err error, stderrText, stdoutText string) error { + detail := sanitizeBitwardenErrorDetail(stderrText, stdoutText) + classification := classifyBitwardenError(detail) + if classification == nil { + classification = ErrBWUnavailable + } + + wrapped := errors.Join(classification, err) + if strings.TrimSpace(detail) == "" { + return wrapped + } + + return fmt.Errorf("%w: %s", wrapped, detail) +} + +func sanitizeBitwardenErrorDetail(stderrText, stdoutText string) string { + raw := strings.TrimSpace(stderrText) + if raw == "" { + raw = strings.TrimSpace(stdoutText) + } + if raw == "" { + return "" + } + + lines := strings.Split(raw, "\n") + cleaned := make([]string, 0, len(lines)) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + lower := strings.ToLower(trimmed) + if strings.HasPrefix(trimmed, "at ") || + strings.HasPrefix(lower, "node:internal") || + strings.HasPrefix(lower, "internal/") || + strings.HasPrefix(lower, "npm ") { + continue + } + + cleaned = append(cleaned, trimmed) + } + + if len(cleaned) == 0 { + return "" + } + + if len(cleaned) == 1 { + return cleaned[0] + } + + return cleaned[0] + " | " + cleaned[1] +} + +func classifyBitwardenError(detail string) error { + lower := strings.ToLower(strings.TrimSpace(detail)) + + switch { + case strings.Contains(lower, "not logged in"), strings.Contains(lower, "unauthenticated"): + return ErrBWNotLoggedIn + case strings.Contains(lower, "vault is locked"), strings.Contains(lower, "is locked"): + return ErrBWLocked + case strings.Contains(lower, "failed to fetch"), + strings.Contains(lower, "econnrefused"), + strings.Contains(lower, "etimedout"), + strings.Contains(lower, "unable to connect"), + strings.Contains(lower, "network"): + return ErrBWUnavailable + default: + return nil + } +} diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 8f6e2f0..9994fb5 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -14,6 +14,7 @@ import ( ) func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) { + withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") withBitwardenRunner(t, fakeCLI.run) @@ -31,9 +32,116 @@ func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) { if !fakeCLI.versionChecked { t.Fatal("expected bitwarden CLI version check") } + if !fakeCLI.statusChecked { + t.Fatal("expected bitwarden CLI status check") + } +} + +func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + return nil, &exec.Error{Name: command, Err: exec.ErrNotFound} + }) + + _, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBackendUnavailable) { + t.Fatalf("error = %v, want ErrBackendUnavailable", err) + } +} + +func TestOpenBitwardenCLIFailsWhenSessionIsMissing(t *testing.T) { + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + _, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + LookupEnv: func(name string) (string, bool) { + return "", false + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBWLocked) { + t.Fatalf("error = %v, want ErrBWLocked", err) + } + if !errors.Is(err, ErrBackendUnavailable) { + t.Fatalf("error = %v, want ErrBackendUnavailable", err) + } +} + +func TestEnsureBitwardenReadyGuidesLoginAndUnlock(t *testing.T) { + t.Run("unauthenticated", func(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"unauthenticated"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + err := EnsureBitwardenReady(Options{BitwardenCommand: "bw"}) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBWNotLoggedIn) { + t.Fatalf("error = %v, want ErrBWNotLoggedIn", err) + } + if !strings.Contains(err.Error(), "bw login") { + t.Fatalf("error = %v, want guidance with bw login", err) + } + }) + + t.Run("locked", func(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"locked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + err := EnsureBitwardenReady(Options{BitwardenCommand: "bw"}) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBWLocked) { + t.Fatalf("error = %v, want ErrBWLocked", err) + } + if !strings.Contains(err.Error(), "bw unlock --raw") { + t.Fatalf("error = %v, want guidance with bw unlock", err) + } + }) +} + +func TestEnsureBitwardenReadyAcceptsUnlockedSession(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"unlocked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + err := EnsureBitwardenReady(Options{ + BitwardenCommand: "bw", + LookupEnv: func(name string) (string, bool) { + if name == "BW_SESSION" { + return "session-token", true + } + return "", false + }, + }) + if err != nil { + t.Fatalf("EnsureBitwardenReady returned error: %v", err) + } } func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) { + withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") withBitwardenRunner(t, fakeCLI.run) @@ -79,24 +187,138 @@ func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) { } } -func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) { - withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - return nil, &exec.Error{Name: command, Err: exec.ErrNotFound} - }) +func TestBitwardenStoreWritesMarkerFields(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) - _, err := Open(Options{ + store, err := Open(Options{ ServiceName: "email-mcp", BackendPolicy: BackendBitwardenCLI, }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + + var found fakeBitwardenItem + for _, item := range fakeCLI.itemsByID { + if item.Name == "email-mcp/api-token" { + found = item + break + } + } + if found.ID == "" { + t.Fatal("expected bitwarden item to be created") + } + if found.MarkerService != "email-mcp" { + t.Fatalf("marker service = %q, want email-mcp", found.MarkerService) + } + if found.MarkerSecretName != "api-token" { + t.Fatalf("marker secret = %q, want api-token", found.MarkerSecretName) + } +} + +func TestBitwardenStorePrefersStrictMarkerMatchWhenNameCollides(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "wrong-secret", + MarkerService: "other-service", + MarkerSecretName: "api-token", + } + fakeCLI.itemsByID["item-2"] = fakeBitwardenItem{ + ID: "item-2", + Name: "email-mcp/api-token", + Secret: "good-secret", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "good-secret" { + t.Fatalf("GetSecret = %q, want good-secret", value) + } +} + +func TestBitwardenStoreFallsBackToSingleLegacyItemWithoutMarkers(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["legacy-1"] = fakeBitwardenItem{ + ID: "legacy-1", + Name: "email-mcp/api-token", + Secret: "legacy-secret", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "legacy-secret" { + t.Fatalf("GetSecret = %q, want legacy-secret", value) + } +} + +func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) { + store := &bitwardenStore{command: "bw", serviceName: "email-mcp"} + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" { + return []byte(""), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + _, err := store.findItem("email-mcp/api-token", "api-token") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("error = %v, want ErrNotFound", err) + } +} + +func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh is required for this test") + } + + _, err := executeBitwardenCLI("sh", nil, "-c", "echo 'You are not logged in.' 1>&2; echo ' at Foo (node:internal/x)' 1>&2; exit 1") if err == nil { t.Fatal("expected error") } - if !errors.Is(err, ErrBackendUnavailable) { - t.Fatalf("error = %v, want ErrBackendUnavailable", err) + if !errors.Is(err, ErrBWNotLoggedIn) { + t.Fatalf("error = %v, want ErrBWNotLoggedIn", err) + } + if strings.Contains(err.Error(), "node:internal") { + t.Fatalf("error = %v, stack trace should be stripped", err) } } func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { + withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") withBitwardenRunner(t, fakeCLI.run) @@ -128,6 +350,11 @@ func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { } } +func withBitwardenSession(t *testing.T) { + t.Helper() + t.Setenv("BW_SESSION", "test-session") +} + func withBitwardenRunner( t *testing.T, runner func(command string, stdin []byte, args ...string) ([]byte, error), @@ -145,14 +372,18 @@ type fakeBitwardenCLI struct { command string itemsByID map[string]fakeBitwardenItem nextID int + status string versionChecked bool + statusChecked bool } type fakeBitwardenItem struct { - ID string - Name string - Notes string - Secret string + ID string + Name string + Notes string + Secret string + MarkerService string + MarkerSecretName string } func newFakeBitwardenCLI(command string) *fakeBitwardenCLI { @@ -160,6 +391,7 @@ func newFakeBitwardenCLI(command string) *fakeBitwardenCLI { command: strings.TrimSpace(command), itemsByID: map[string]fakeBitwardenItem{}, nextID: 1, + status: "unlocked", } } @@ -175,6 +407,10 @@ func (f *fakeBitwardenCLI) run(command string, stdin []byte, args ...string) ([] f.versionChecked = true return []byte("2026.1.0\n"), nil } + if len(args) == 1 && args[0] == "status" { + f.statusChecked = true + return []byte(fmt.Sprintf(`{"status":%q}`, strings.TrimSpace(f.status))), nil + } switch { case len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search": @@ -205,12 +441,10 @@ func (f *fakeBitwardenCLI) handleListItems(search string) ([]byte, error) { continue } items = append(items, map[string]any{ - "id": item.ID, - "name": item.Name, - "notes": item.Notes, - "fields": []map[string]any{ - {"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1}, - }, + "id": item.ID, + "name": item.Name, + "notes": item.Notes, + "fields": item.fieldsPayload(), }) } @@ -228,12 +462,10 @@ func (f *fakeBitwardenCLI) handleGetItem(id string) ([]byte, error) { } payload, err := json.Marshal(map[string]any{ - "id": item.ID, - "name": item.Name, - "notes": item.Notes, - "fields": []map[string]any{ - {"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1}, - }, + "id": item.ID, + "name": item.Name, + "notes": item.Notes, + "fields": item.fieldsPayload(), "secureNote": map[string]any{"type": 0}, }) if err != nil { @@ -250,10 +482,12 @@ func (f *fakeBitwardenCLI) handleCreateItem(encoded string) ([]byte, error) { } item := fakeBitwardenItem{ - ID: fmt.Sprintf("item-%d", f.nextID), - Name: readString(payload, "name"), - Notes: readString(payload, "notes"), - Secret: readFakeBitwardenSecret(payload), + ID: fmt.Sprintf("item-%d", f.nextID), + Name: readString(payload, "name"), + Notes: readString(payload, "notes"), + Secret: readFakeBitwardenField(payload, bitwardenSecretFieldName), + MarkerService: readFakeBitwardenField(payload, bitwardenServiceFieldName), + MarkerSecretName: readFakeBitwardenField(payload, bitwardenSecretNameFieldName), } f.nextID++ f.itemsByID[item.ID] = item @@ -278,10 +512,12 @@ func (f *fakeBitwardenCLI) handleEditItem(id, encoded string) ([]byte, error) { } item := fakeBitwardenItem{ - ID: trimmedID, - Name: readString(payload, "name"), - Notes: readString(payload, "notes"), - Secret: readFakeBitwardenSecret(payload), + ID: trimmedID, + Name: readString(payload, "name"), + Notes: readString(payload, "notes"), + Secret: readFakeBitwardenField(payload, bitwardenSecretFieldName), + MarkerService: readFakeBitwardenField(payload, bitwardenServiceFieldName), + MarkerSecretName: readFakeBitwardenField(payload, bitwardenSecretNameFieldName), } f.itemsByID[trimmedID] = item @@ -307,7 +543,7 @@ func decodeBitwardenPayload(encoded string) (map[string]any, error) { return payload, nil } -func readFakeBitwardenSecret(payload map[string]any) string { +func readFakeBitwardenField(payload map[string]any, fieldName string) string { rawFields, ok := payload["fields"] if !ok { return "" @@ -325,7 +561,7 @@ func readFakeBitwardenSecret(payload map[string]any) string { } name := strings.TrimSpace(readString(field, "name")) - if name != bitwardenSecretFieldName { + if name != fieldName { continue } @@ -335,6 +571,21 @@ func readFakeBitwardenSecret(payload map[string]any) string { return "" } +func (i fakeBitwardenItem) fieldsPayload() []map[string]any { + fields := []map[string]any{ + {"name": bitwardenSecretFieldName, "value": i.Secret, "type": 1}, + } + + if strings.TrimSpace(i.MarkerService) != "" { + fields = append(fields, map[string]any{"name": bitwardenServiceFieldName, "value": i.MarkerService, "type": 0}) + } + if strings.TrimSpace(i.MarkerSecretName) != "" { + fields = append(fields, map[string]any{"name": bitwardenSecretNameFieldName, "value": i.MarkerSecretName, "type": 0}) + } + + return fields +} + func readString(payload map[string]any, key string) string { value, _ := payload[key].(string) return strings.TrimSpace(value) diff --git a/secretstore/store.go b/secretstore/store.go index d957562..2b9bb5f 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -16,6 +16,9 @@ var ErrNotFound = errors.New("secret not found") var ErrBackendUnavailable = errors.New("secret backend unavailable") var ErrReadOnly = errors.New("secret backend is read-only") var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy") +var ErrBWNotLoggedIn = errors.New("bitwarden is not logged in") +var ErrBWLocked = errors.New("bitwarden vault is locked or BW_SESSION is missing") +var ErrBWUnavailable = errors.New("bitwarden CLI unavailable") type BackendPolicy string @@ -140,6 +143,40 @@ func BackendName() string { } } +func EffectiveBackendPolicy(store Store) BackendPolicy { + switch store.(type) { + case *bitwardenStore: + return BackendBitwardenCLI + case *envStore: + return BackendEnvOnly + case *keyringStore: + return BackendKeyringAny + default: + return "" + } +} + +func SetSecretVerified(store Store, name, label, secret string) error { + if store == nil { + return errors.New("secret store must not be nil") + } + + if err := store.SetSecret(name, label, secret); err != nil { + return err + } + + verified, err := store.GetSecret(name) + if err != nil { + return fmt.Errorf("verify secret %q after write: %w", name, err) + } + + if verified != secret { + return fmt.Errorf("verify secret %q after write: read-back mismatch", name) + } + + return nil +} + func SetJSON[T any](store Store, name, label string, value T) error { data, err := json.Marshal(value) if err != nil { diff --git a/secretstore/store_test.go b/secretstore/store_test.go index 818ea41..1ddd6c6 100644 --- a/secretstore/store_test.go +++ b/secretstore/store_test.go @@ -248,3 +248,88 @@ func TestJSONHelpersRoundTrip(t *testing.T) { t.Fatalf("GetJSON = %#v, want %#v", output, input) } } + +func TestEffectiveBackendPolicyReportsConcreteBackend(t *testing.T) { + t.Run("env-only", func(t *testing.T) { + store, err := Open(Options{ + BackendPolicy: BackendEnvOnly, + LookupEnv: func(string) (string, bool) { return "", false }, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if got := EffectiveBackendPolicy(store); got != BackendEnvOnly { + t.Fatalf("EffectiveBackendPolicy = %q, want %q", got, BackendEnvOnly) + } + }) + + t.Run("keyring", func(t *testing.T) { + withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) { + return &stubKeyring{}, nil + }) + + store, err := Open(Options{ + ServiceName: "mcp-framework-test", + BackendPolicy: BackendAuto, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if got := EffectiveBackendPolicy(store); got != BackendKeyringAny { + t.Fatalf("EffectiveBackendPolicy = %q, want %q", got, BackendKeyringAny) + } + }) +} + +func TestSetSecretVerifiedWritesThenReadsBack(t *testing.T) { + ring := &stubKeyring{} + withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) { + return ring, nil + }) + + store, err := Open(Options{ + ServiceName: "mcp-framework-test", + BackendPolicy: BackendAuto, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if err := SetSecretVerified(store, "token", "API token", "secret-value"); err != nil { + t.Fatalf("SetSecretVerified returned error: %v", err) + } +} + +func TestSetSecretVerifiedFailsOnReadBackMismatch(t *testing.T) { + store := &mismatchSecretStore{} + + err := SetSecretVerified(store, "token", "API token", "secret-value") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrNotFound) { + t.Fatalf("error = %v, want wrapped ErrNotFound", err) + } + if store.setCalls != 1 { + t.Fatalf("setCalls = %d, want 1", store.setCalls) + } +} + +type mismatchSecretStore struct { + setCalls int +} + +func (s *mismatchSecretStore) SetSecret(name, label, secret string) error { + s.setCalls++ + return nil +} + +func (s *mismatchSecretStore) GetSecret(name string) (string, error) { + return "", ErrNotFound +} + +func (s *mismatchSecretStore) DeleteSecret(name string) error { + return nil +} -- 2.45.2 From 7d159bfdbd6a4ed7cc7a3ff27f436646e7600e2a Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 10:56:15 +0200 Subject: [PATCH 34/79] feat: add runtime secretstore diagnostics and setup helpers --- cli/doctor.go | 53 +++++++-- cli/doctor_test.go | 82 ++++++++++++++ cli/setup_secret.go | 76 +++++++++++++ cli/setup_secret_test.go | 116 ++++++++++++++++++++ docs/cli-helpers.md | 27 +++-- docs/packages.md | 2 +- docs/secrets.md | 76 +++++++++++++ scaffold/scaffold.go | 57 +++------- scaffold/scaffold_test.go | 2 + secretstore/bitwarden.go | 63 ++++++++++- secretstore/bitwarden_test.go | 48 +++++++++ secretstore/manifest_open.go | 54 +++++++--- secretstore/runtime.go | 196 ++++++++++++++++++++++++++++++++++ secretstore/runtime_test.go | 155 +++++++++++++++++++++++++++ secretstore/store.go | 1 + 15 files changed, 934 insertions(+), 74 deletions(-) create mode 100644 cli/setup_secret.go create mode 100644 cli/setup_secret_test.go create mode 100644 secretstore/runtime.go create mode 100644 secretstore/runtime_test.go diff --git a/cli/doctor.go b/cli/doctor.go index ec859e7..293806e 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -49,19 +49,23 @@ type DoctorSecret struct { type DoctorManifestValidator func(manifest.File, string) []string type DoctorOptions struct { - ConfigCheck DoctorCheck - SecretStoreCheck DoctorCheck - RequiredSecrets []DoctorSecret - SecretStoreFactory func() (secretstore.Store, error) - ManifestDir string - ManifestValidator DoctorManifestValidator - ManifestCheck DoctorCheck - ConnectivityCheck DoctorCheck - ExtraChecks []DoctorCheck + ConfigCheck DoctorCheck + SecretStoreCheck DoctorCheck + SecretBackendPolicy secretstore.BackendPolicy + RequiredSecrets []DoctorSecret + SecretStoreFactory func() (secretstore.Store, error) + ManifestDir string + ManifestValidator DoctorManifestValidator + ManifestCheck DoctorCheck + ConnectivityCheck DoctorCheck + BitwardenOptions BitwardenDoctorOptions + DisableAutoBitwardenCheck bool + ExtraChecks []DoctorCheck } type BitwardenDoctorOptions struct { Command string + Shell string LookupEnv func(string) (string, bool) } @@ -87,6 +91,9 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport { if options.ConnectivityCheck != nil { checks = append(checks, options.ConnectivityCheck) } + if shouldAutoIncludeBitwardenCheck(options) { + checks = append(checks, BitwardenReadyCheck(options.BitwardenOptions)) + } checks = append(checks, options.ExtraChecks...) results := make([]DoctorResult, 0, len(checks)) @@ -231,6 +238,7 @@ func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck { return func(context.Context) DoctorResult { err := checkBitwardenReady(secretstore.Options{ BitwardenCommand: strings.TrimSpace(options.Command), + Shell: strings.TrimSpace(options.Shell), LookupEnv: options.LookupEnv, }) if err == nil { @@ -274,6 +282,33 @@ func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck { } } +func shouldAutoIncludeBitwardenCheck(options DoctorOptions) bool { + if options.DisableAutoBitwardenCheck { + return false + } + + if options.SecretBackendPolicy == secretstore.BackendBitwardenCLI { + return true + } + + if options.SecretStoreFactory == nil { + return false + } + + store, err := options.SecretStoreFactory() + if err == nil { + return secretstore.EffectiveBackendPolicy(store) == secretstore.BackendBitwardenCLI + } + + if errors.Is(err, secretstore.ErrBWNotLoggedIn) || + errors.Is(err, secretstore.ErrBWLocked) || + errors.Is(err, secretstore.ErrBWUnavailable) { + return true + } + + return strings.Contains(strings.ToLower(strings.TrimSpace(err.Error())), "bitwarden") +} + func RequiredSecretsCheck(factory func() (secretstore.Store, error), required []DoctorSecret) DoctorCheck { return func(context.Context) DoctorResult { store, err := factory() diff --git a/cli/doctor_test.go b/cli/doctor_test.go index 698cd74..bbdc15e 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -253,6 +253,88 @@ func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) }) } +func TestRunDoctorAutoInjectsBitwardenCheckForBitwardenPolicy(t *testing.T) { + prev := checkBitwardenReady + t.Cleanup(func() { + checkBitwardenReady = prev + }) + + lookupCalled := false + checkBitwardenReady = func(options secretstore.Options) error { + if options.Shell != "fish" { + t.Fatalf("Shell = %q, want fish", options.Shell) + } + if options.BitwardenCommand != "bw" { + t.Fatalf("BitwardenCommand = %q, want bw", options.BitwardenCommand) + } + if options.LookupEnv == nil { + t.Fatal("LookupEnv should be forwarded") + } + _, _ = options.LookupEnv("BW_SESSION") + return fmt.Errorf("%w: run unlock", secretstore.ErrBWLocked) + } + + report := RunDoctor(context.Background(), DoctorOptions{ + SecretBackendPolicy: secretstore.BackendBitwardenCLI, + BitwardenOptions: BitwardenDoctorOptions{ + Command: "bw", + Shell: "fish", + LookupEnv: func(name string) (string, bool) { + lookupCalled = true + return "", false + }, + }, + ManifestCheck: func(context.Context) DoctorResult { + return DoctorResult{Name: "manifest", Status: DoctorStatusOK, Summary: "manifest ok"} + }, + }) + + if !lookupCalled { + t.Fatal("LookupEnv should be used by auto bitwarden check") + } + + var found *DoctorResult + for i := range report.Results { + if report.Results[i].Name == "bitwarden" { + found = &report.Results[i] + break + } + } + if found == nil { + t.Fatalf("report results = %#v, want auto bitwarden check", report.Results) + } + if found.Status != DoctorStatusFail { + t.Fatalf("bitwarden status = %q, want fail", found.Status) + } +} + +func TestRunDoctorCanDisableAutoBitwardenCheck(t *testing.T) { + prev := checkBitwardenReady + t.Cleanup(func() { + checkBitwardenReady = prev + }) + + checkBitwardenReady = func(options secretstore.Options) error { + t.Fatal("checkBitwardenReady should not be called when auto check is disabled") + return nil + } + + report := RunDoctor(context.Background(), DoctorOptions{ + DisableAutoBitwardenCheck: true, + SecretBackendPolicy: secretstore.BackendBitwardenCLI, + ManifestCheck: func(context.Context) DoctorResult { + return DoctorResult{Name: "manifest", Status: DoctorStatusOK, Summary: "manifest ok"} + }, + }) + + if len(report.Results) != 1 { + t.Fatalf("result count = %d, want 1", len(report.Results)) + } + if report.Results[0].Name != "manifest" { + t.Fatalf("result name = %q, want manifest", report.Results[0].Name) + } +} + func TestRequiredResolvedFieldsCheckReportsSources(t *testing.T) { check := RequiredResolvedFieldsCheck(ResolveOptions{ Fields: []FieldSpec{ diff --git a/cli/setup_secret.go b/cli/setup_secret.go new file mode 100644 index 0000000..dceaacd --- /dev/null +++ b/cli/setup_secret.go @@ -0,0 +1,76 @@ +package cli + +import ( + "errors" + "fmt" + "strings" + + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type SetupSecretWriteOptions struct { + Store secretstore.Store + SecretName string + SecretLabel string + TokenEnv string + Value SetupValue +} + +func WriteSetupSecretVerified(options SetupSecretWriteOptions) error { + if options.Store == nil { + return errors.New("secret store must not be nil") + } + + secretName := strings.TrimSpace(options.SecretName) + if secretName == "" { + return errors.New("secret name must not be empty") + } + + secretLabel := strings.TrimSpace(options.SecretLabel) + if secretLabel == "" { + secretLabel = secretName + } + + if options.Value.KeptStoredSecret { + return verifyStoredSetupSecret(options.Store, secretName, options.TokenEnv) + } + if !options.Value.Set { + return nil + } + + if err := secretstore.SetSecretVerified(options.Store, secretName, secretLabel, options.Value.String); err != nil { + if errors.Is(err, secretstore.ErrReadOnly) { + tokenEnv := strings.TrimSpace(options.TokenEnv) + if tokenEnv != "" { + return fmt.Errorf("secret store is read-only, export %s and retry setup: %w", tokenEnv, err) + } + } + return fmt.Errorf("save secret %q during setup: %w", secretName, err) + } + + return nil +} + +func verifyStoredSetupSecret(store secretstore.Store, secretName, tokenEnv string) error { + secret, err := store.GetSecret(secretName) + if err != nil { + if errors.Is(err, secretstore.ErrNotFound) { + tokenEnv = strings.TrimSpace(tokenEnv) + if tokenEnv != "" { + return fmt.Errorf( + "secret %q is not readable after setup, export %s and retry: %w", + secretName, + tokenEnv, + err, + ) + } + } + return fmt.Errorf("verify secret %q after setup: %w", secretName, err) + } + + if strings.TrimSpace(secret) == "" { + return fmt.Errorf("secret %q is empty after setup", secretName) + } + + return nil +} diff --git a/cli/setup_secret_test.go b/cli/setup_secret_test.go new file mode 100644 index 0000000..d426459 --- /dev/null +++ b/cli/setup_secret_test.go @@ -0,0 +1,116 @@ +package cli + +import ( + "errors" + "strings" + "testing" + + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +func TestWriteSetupSecretVerifiedPersistsAndConfirmsReadability(t *testing.T) { + store := &setupSecretStore{secrets: map[string]string{}} + + err := WriteSetupSecretVerified(SetupSecretWriteOptions{ + Store: store, + SecretName: "api-token", + SecretLabel: "API token", + Value: SetupValue{ + Type: SetupFieldSecret, + String: "secret-v1", + Set: true, + }, + }) + if err != nil { + t.Fatalf("WriteSetupSecretVerified returned error: %v", err) + } + + if got := store.secrets["api-token"]; got != "secret-v1" { + t.Fatalf("stored secret = %q, want secret-v1", got) + } +} + +func TestWriteSetupSecretVerifiedReturnsContextForReadOnlyStores(t *testing.T) { + store := &setupSecretStore{ + secrets: map[string]string{}, + setErr: secretstore.ErrReadOnly, + } + + err := WriteSetupSecretVerified(SetupSecretWriteOptions{ + Store: store, + SecretName: "api-token", + TokenEnv: "GRAYLOG_MCP_API_TOKEN", + Value: SetupValue{ + Type: SetupFieldSecret, + String: "secret-v1", + Set: true, + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, secretstore.ErrReadOnly) { + t.Fatalf("error = %v, want ErrReadOnly", err) + } + if !strings.Contains(err.Error(), "GRAYLOG_MCP_API_TOKEN") { + t.Fatalf("error = %v, want token env remediation", err) + } +} + +func TestWriteSetupSecretVerifiedValidatesKeptStoredSecret(t *testing.T) { + store := &setupSecretStore{ + secrets: map[string]string{}, + getErr: secretstore.ErrNotFound, + } + + err := WriteSetupSecretVerified(SetupSecretWriteOptions{ + Store: store, + SecretName: "api-token", + TokenEnv: "GRAYLOG_MCP_API_TOKEN", + Value: SetupValue{ + Type: SetupFieldSecret, + String: "stored-token", + Set: true, + KeptStoredSecret: true, + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, secretstore.ErrNotFound) { + t.Fatalf("error = %v, want ErrNotFound", err) + } + if !strings.Contains(err.Error(), "GRAYLOG_MCP_API_TOKEN") { + t.Fatalf("error = %v, want token env remediation", err) + } +} + +type setupSecretStore struct { + secrets map[string]string + setErr error + getErr error +} + +func (s *setupSecretStore) SetSecret(name, label, secret string) error { + if s.setErr != nil { + return s.setErr + } + s.secrets[name] = secret + return nil +} + +func (s *setupSecretStore) GetSecret(name string) (string, error) { + if s.getErr != nil { + return "", s.getErr + } + value, ok := s.secrets[name] + if !ok { + return "", secretstore.ErrNotFound + } + return value, nil +} + +func (s *setupSecretStore) DeleteSecret(name string) error { + delete(s.secrets, name) + return nil +} diff --git a/docs/cli-helpers.md b/docs/cli-helpers.md index 69d97b3..7f59080 100644 --- a/docs/cli-helpers.md +++ b/docs/cli-helpers.md @@ -73,6 +73,20 @@ _ = enabled _ = scopes ``` +Pour persister un secret de setup avec write+read-back et messages homogènes : + +```go +if err := cli.WriteSetupSecretVerified(cli.SetupSecretWriteOptions{ + Store: store, + SecretName: "api-token", + SecretLabel: "API token", + TokenEnv: "MY_MCP_API_TOKEN", + Value: apiToken, +}); err != nil { + return err +} +``` + Chaque champ peut déclarer ses propres hooks de validation (`Validate`, `ValidateBool`, `ValidateList`). Les validations sont appliquées de manière cohérente en TTY et en stdin non interactif. @@ -123,6 +137,10 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ ServiceName: "my-mcp", }) }), + SecretBackendPolicy: secretstore.BackendBitwardenCLI, + BitwardenOptions: cli.BitwardenDoctorOptions{ + LookupEnv: os.LookupEnv, + }, RequiredSecrets: []cli.DoctorSecret{ {Name: "api-token", Label: "API token"}, }, @@ -158,15 +176,12 @@ if report.HasFailures() { } ``` -Pour une policy `bitwarden-cli`, tu peux ajouter un check dédié avec remédiations : +Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement. +Pour le désactiver explicitement : ```go report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ - ExtraChecks: []cli.DoctorCheck{ - cli.BitwardenReadyCheck(cli.BitwardenDoctorOptions{ - LookupEnv: os.LookupEnv, - }), - }, + DisableAutoBitwardenCheck: true, }) ``` diff --git a/docs/packages.md b/docs/packages.md index 4c181c2..1e197e2 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -5,5 +5,5 @@ - `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`. - `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. - `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage). -- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`. +- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helpers runtime `OpenFromManifest`, `DescribeRuntime`, `PreflightFromManifest` et formatage homogène via `FormatBackendStatus`. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. diff --git a/docs/secrets.md b/docs/secrets.md index 9d9e683..24046d1 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -131,5 +131,81 @@ effective := secretstore.EffectiveBackendPolicy(store) fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any... ``` +Pour obtenir en un seul appel une description runtime (source manifeste, policy déclarée/effective, disponibilité) : + +```go +desc, err := secretstore.DescribeRuntime(secretstore.DescribeRuntimeOptions{ + ServiceName: "my-mcp", + LookupEnv: os.LookupEnv, +}) +if err != nil { + return err +} + +fmt.Println(secretstore.FormatBackendStatus(desc)) +// declared=... effective=... display=... ready=... source=... +``` + +Pour un préflight réutilisable dans `setup`, `config show` et `config test` : + +```go +report, err := secretstore.PreflightFromManifest(secretstore.PreflightOptions{ + ServiceName: "my-mcp", + LookupEnv: os.LookupEnv, +}) +if err != nil { + return err +} + +fmt.Println(report.Status) // ready | fail +fmt.Println(report.Summary) // message court +fmt.Println(report.Remediation) // action recommandée +``` + +## Debug Bitwarden en 60 secondes + +1. Vérifier l'état de session : + +```bash +bw status +``` + +2. Déverrouiller le vault et exporter `BW_SESSION` : + +- Bash/Zsh : + +```bash +export BW_SESSION="$(bw unlock --raw)" +``` + +- Fish : + +```fish +set -x BW_SESSION (bw unlock --raw) +``` + +- PowerShell : + +```powershell +$env:BW_SESSION = (bw unlock --raw) +``` + +3. Vérifier lecture/écriture rapide : + +```go +if err := store.SetSecret("debug-token", "Debug token", "ok"); err != nil { + return err +} +_, err := store.GetSecret("debug-token") +return err +``` + +4. Interpréter les erreurs typées : + +- `secretstore.ErrBWNotLoggedIn` : `bw login` requis. +- `secretstore.ErrBWLocked` : vault verrouillé ou `BW_SESSION` absent. +- `secretstore.ErrBWUnavailable` : CLI/réseau indisponible. +- `secretstore.ErrBackendUnavailable` : policy non satisfiable dans le contexte courant. + En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`. Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`. diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index a94b97c..1856f3c 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -1731,37 +1731,19 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { return err } - if !tokenValue.KeptStoredSecret { - store, err := r.openSecretStore() - if err != nil { - return err - } - - if err := secretstore.SetSecretVerified(store, r.SecretName, "API token", tokenValue.String); err != nil { - if errors.Is(err, secretstore.ErrReadOnly) { - return fmt.Errorf( - "secret store is read-only, export %s and retry setup", - r.TokenEnv, - ) - } else { - return err - } - } - } - - verifiedToken, err := r.readToken() + store, err := r.openSecretStore() if err != nil { - if errors.Is(err, secretstore.ErrNotFound) { - return fmt.Errorf( - "secret %q is not readable after setup, export %s and retry", - r.SecretName, - r.TokenEnv, - ) - } return err } - if strings.TrimSpace(verifiedToken) == "" { - return fmt.Errorf("secret %q is empty after setup", r.SecretName) + + if err := cli.WriteSetupSecretVerified(cli.SetupSecretWriteOptions{ + Store: store, + SecretName: r.SecretName, + SecretLabel: "API token", + TokenEnv: r.TokenEnv, + Value: tokenValue, + }); err != nil { + return err } _, err = fmt.Fprintf(stdout, "Configuration saved for profile %q. Secret readability confirmed.\n", profileName) @@ -1835,15 +1817,16 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er } report := cli.RunDoctor(ctx, cli.DoctorOptions{ - ConfigCheck: cli.NewConfigCheck(r.ConfigStore), - SecretStoreCheck: cli.SecretStoreAvailabilityCheck(r.openSecretStore), + ConfigCheck: cli.NewConfigCheck(r.ConfigStore), + SecretStoreCheck: cli.SecretStoreAvailabilityCheck(r.openSecretStore), + SecretBackendPolicy: r.activeBackendPolicy(), RequiredSecrets: []cli.DoctorSecret{ {Name: r.SecretName, Label: "API token"}, }, SecretStoreFactory: r.openSecretStore, ManifestCheck: r.manifestDoctorCheck(), - ExtraChecks: []cli.DoctorCheck{ - r.bitwardenDoctorCheck(), + BitwardenOptions: cli.BitwardenDoctorOptions{ + LookupEnv: os.LookupEnv, }, }) @@ -1947,16 +1930,6 @@ func (r Runtime) manifestDoctorCheck() cli.DoctorCheck { } } } - -func (r Runtime) bitwardenDoctorCheck() cli.DoctorCheck { - if r.activeBackendPolicy() != secretstore.BackendBitwardenCLI { - return nil - } - - return cli.BitwardenReadyCheck(cli.BitwardenDoctorOptions{ - LookupEnv: os.LookupEnv, - }) -} ` const manifestTemplate = `binary_name = "{{.BinaryName}}" diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 67e3aef..5880090 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -75,6 +75,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { `var embeddedManifest = `, "ManifestSource", "ManifestCheck: r.manifestDoctorCheck()", + "SecretBackendPolicy: r.activeBackendPolicy()", + "cli.WriteSetupSecretVerified", } { if !strings.Contains(string(appGo), snippet) { t.Fatalf("app.go missing snippet %q", snippet) diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 2a29cee..ffcc0f7 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -7,6 +7,8 @@ import ( "fmt" "os" "os/exec" + "path/filepath" + "runtime" "strings" ) @@ -68,6 +70,7 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string if err := EnsureBitwardenReady(Options{ BitwardenCommand: command, LookupEnv: options.LookupEnv, + Shell: options.Shell, }); err != nil { return nil, fmt.Errorf( "secret backend policy %q cannot use bitwarden CLI command %q right now: %w", @@ -85,6 +88,7 @@ func EnsureBitwardenReady(options Options) error { if command == "" { command = defaultBitwardenCommand } + unlockCommand := bitwardenUnlockRemediation(command, options.Shell) lookupEnv := options.LookupEnv if lookupEnv == nil { @@ -111,18 +115,18 @@ func EnsureBitwardenReady(options Options) error { return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn) case "locked": return fmt.Errorf( - "%w: run `export %s=\"$(bw unlock --raw)\"` then retry", + "%w: run `%s` then retry", ErrBWLocked, - bitwardenSessionEnvName, + unlockCommand, ) case "unlocked": session, ok := lookupEnv(bitwardenSessionEnvName) if !ok || strings.TrimSpace(session) == "" { return fmt.Errorf( - "%w: environment variable %q is missing; run `export %s=\"$(bw unlock --raw)\"` then retry", + "%w: environment variable %q is missing; run `%s` then retry", ErrBWLocked, bitwardenSessionEnvName, - bitwardenSessionEnvName, + unlockCommand, ) } return nil @@ -135,6 +139,57 @@ func EnsureBitwardenReady(options Options) error { } } +func bitwardenUnlockRemediation(command, shellHint string) string { + unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command)) + + switch detectShellFlavor(shellHint) { + case "fish": + return fmt.Sprintf("set -x %s (%s)", bitwardenSessionEnvName, unlockCommand) + case "powershell": + return fmt.Sprintf("$env:%s = (%s)", bitwardenSessionEnvName, unlockCommand) + case "cmd": + return fmt.Sprintf( + "for /f \"usebackq delims=\" %%i in (`%s`) do set %s=%%i", + unlockCommand, + bitwardenSessionEnvName, + ) + default: + return fmt.Sprintf("export %s=\"$(%s)\"", bitwardenSessionEnvName, unlockCommand) + } +} + +func detectShellFlavor(shellHint string) string { + raw := strings.TrimSpace(shellHint) + if raw == "" { + raw = strings.TrimSpace(os.Getenv("SHELL")) + } + if raw == "" { + raw = strings.TrimSpace(os.Getenv("COMSPEC")) + } + if raw == "" && runtime.GOOS == "windows" { + return "powershell" + } + + lower := strings.ToLower(strings.TrimSpace(raw)) + base := strings.ToLower(filepath.Base(lower)) + + switch { + case strings.Contains(lower, "powershell"), + strings.Contains(lower, "pwsh"), + base == "powershell", + base == "powershell.exe", + base == "pwsh", + base == "pwsh.exe": + return "powershell" + case strings.Contains(lower, "fish"), base == "fish": + return "fish" + case strings.Contains(lower, "cmd.exe"), base == "cmd", base == "cmd.exe": + return "cmd" + default: + return "posix" + } +} + func (s *bitwardenStore) SetSecret(name, label, secret string) error { secretName := s.scopedName(name) item, err := s.findItem(secretName, name) diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 9994fb5..e12a662 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -140,6 +140,54 @@ func TestEnsureBitwardenReadyAcceptsUnlockedSession(t *testing.T) { } } +func TestEnsureBitwardenReadyAdaptsUnlockRemediationToShell(t *testing.T) { + t.Run("fish", func(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"locked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + err := EnsureBitwardenReady(Options{BitwardenCommand: "bw", Shell: "fish"}) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBWLocked) { + t.Fatalf("error = %v, want ErrBWLocked", err) + } + if !strings.Contains(err.Error(), "set -x BW_SESSION (bw unlock --raw)") { + t.Fatalf("error = %v, want fish unlock remediation", err) + } + }) + + t.Run("powershell", func(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"unlocked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + err := EnsureBitwardenReady(Options{ + BitwardenCommand: "bw", + Shell: "powershell", + LookupEnv: func(name string) (string, bool) { + return "", false + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBWLocked) { + t.Fatalf("error = %v, want ErrBWLocked", err) + } + if !strings.Contains(err.Error(), "$env:BW_SESSION = (bw unlock --raw)") { + t.Fatalf("error = %v, want powershell unlock remediation", err) + } + }) +} + func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) { withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") diff --git a/secretstore/manifest_open.go b/secretstore/manifest_open.go index 71e0390..9c95653 100644 --- a/secretstore/manifest_open.go +++ b/secretstore/manifest_open.go @@ -19,26 +19,43 @@ type OpenFromManifestOptions struct { LookupEnv func(string) (string, bool) KWalletAppID string KWalletFolder string + BitwardenCommand string + Shell string ManifestLoader ManifestLoader ExecutableResolver ExecutableResolver } func OpenFromManifest(options OpenFromManifestOptions) (Store, error) { - policy, err := resolveManifestBackendPolicy(options) + manifestPolicy, err := resolveManifestPolicy(options) if err != nil { return nil, err } return Open(Options{ - ServiceName: options.ServiceName, - BackendPolicy: policy, - LookupEnv: options.LookupEnv, - KWalletAppID: options.KWalletAppID, - KWalletFolder: options.KWalletFolder, + ServiceName: options.ServiceName, + BackendPolicy: manifestPolicy.Policy, + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + BitwardenCommand: strings.TrimSpace(options.BitwardenCommand), + Shell: strings.TrimSpace(options.Shell), }) } func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolicy, error) { + resolution, err := resolveManifestPolicy(options) + if err != nil { + return "", err + } + return resolution.Policy, nil +} + +type manifestPolicyResolution struct { + Policy BackendPolicy + Source string +} + +func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) { manifestLoader := options.ManifestLoader if manifestLoader == nil { manifestLoader = manifest.LoadDefault @@ -51,7 +68,7 @@ func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolic executablePath, err := executableResolver() if err != nil { - return "", fmt.Errorf("resolve executable path for manifest lookup: %w", err) + return manifestPolicyResolution{}, fmt.Errorf("resolve executable path for manifest lookup: %w", err) } startDir := filepath.Dir(strings.TrimSpace(executablePath)) @@ -62,19 +79,32 @@ func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolic file, manifestPath, err := manifestLoader(startDir) if err != nil { if errors.Is(err, os.ErrNotExist) { - return BackendAuto, nil + return manifestPolicyResolution{ + Policy: BackendAuto, + Source: "", + }, nil } - return "", fmt.Errorf("load runtime manifest from %q: %w", startDir, err) + return manifestPolicyResolution{}, fmt.Errorf("load runtime manifest from %q: %w", startDir, err) } if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" { - return BackendAuto, nil + return manifestPolicyResolution{ + Policy: BackendAuto, + Source: strings.TrimSpace(manifestPath), + }, nil } policy, err := normalizeBackendPolicy(BackendPolicy(file.SecretStore.BackendPolicy)) if err != nil { - return "", fmt.Errorf("invalid secret_store.backend_policy in manifest %q: %w", strings.TrimSpace(manifestPath), err) + return manifestPolicyResolution{}, fmt.Errorf( + "invalid secret_store.backend_policy in manifest %q: %w", + strings.TrimSpace(manifestPath), + err, + ) } - return policy, nil + return manifestPolicyResolution{ + Policy: policy, + Source: strings.TrimSpace(manifestPath), + }, nil } diff --git a/secretstore/runtime.go b/secretstore/runtime.go new file mode 100644 index 0000000..ec724dc --- /dev/null +++ b/secretstore/runtime.go @@ -0,0 +1,196 @@ +package secretstore + +import ( + "errors" + "fmt" + "strings" +) + +const DefaultManifestSource = "default:auto (manifest not found)" + +type DescribeRuntimeOptions struct { + ServiceName string + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + Shell string + ManifestLoader ManifestLoader + ExecutableResolver ExecutableResolver +} + +type RuntimeDescription struct { + ManifestSource string + DeclaredPolicy BackendPolicy + EffectivePolicy BackendPolicy + DisplayName string + Ready bool + ReadyError error +} + +type PreflightStatus string + +const ( + PreflightStatusReady PreflightStatus = "ready" + PreflightStatusFail PreflightStatus = "fail" +) + +type PreflightOptions = DescribeRuntimeOptions + +type PreflightReport struct { + Status PreflightStatus + Summary string + Remediation string + Runtime RuntimeDescription +} + +func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) { + resolution, err := resolveManifestPolicy(OpenFromManifestOptions{ + ServiceName: options.ServiceName, + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + BitwardenCommand: options.BitwardenCommand, + Shell: options.Shell, + ManifestLoader: options.ManifestLoader, + ExecutableResolver: options.ExecutableResolver, + }) + if err != nil { + return RuntimeDescription{}, err + } + + desc := RuntimeDescription{ + ManifestSource: manifestSourceLabel(resolution.Source), + DeclaredPolicy: resolution.Policy, + EffectivePolicy: resolution.Policy, + DisplayName: BackendDisplayName(resolution.Policy), + } + + store, openErr := Open(Options{ + ServiceName: options.ServiceName, + BackendPolicy: resolution.Policy, + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + BitwardenCommand: options.BitwardenCommand, + Shell: options.Shell, + }) + if openErr != nil { + desc.Ready = false + desc.ReadyError = openErr + return desc, nil + } + + desc.Ready = true + if effective := EffectiveBackendPolicy(store); strings.TrimSpace(string(effective)) != "" { + desc.EffectivePolicy = effective + desc.DisplayName = BackendDisplayName(effective) + } + + return desc, nil +} + +func PreflightFromManifest(options PreflightOptions) (PreflightReport, error) { + desc, err := DescribeRuntime(options) + if err != nil { + return PreflightReport{}, err + } + + if desc.Ready { + return PreflightReport{ + Status: PreflightStatusReady, + Summary: "secret backend is ready", + Runtime: desc, + }, nil + } + + summary, remediation := summarizePreflightFailure(desc.ReadyError) + return PreflightReport{ + Status: PreflightStatusFail, + Summary: summary, + Remediation: remediation, + Runtime: desc, + }, nil +} + +func BackendDisplayName(policy BackendPolicy) string { + switch policy { + case BackendBitwardenCLI: + return "Bitwarden CLI" + case BackendEnvOnly: + return "Environment variables" + case BackendKWalletOnly: + return "KWallet" + case BackendAuto: + return "automatic backend selection" + case BackendKeyringAny: + return BackendName() + default: + trimmed := strings.TrimSpace(string(policy)) + if trimmed == "" { + return "unknown backend" + } + return trimmed + } +} + +func FormatBackendStatus(desc RuntimeDescription) string { + source := manifestSourceLabel(desc.ManifestSource) + display := strings.TrimSpace(desc.DisplayName) + if display == "" { + display = BackendDisplayName(desc.EffectivePolicy) + } + + effective := desc.EffectivePolicy + if strings.TrimSpace(string(effective)) == "" { + effective = desc.DeclaredPolicy + } + + parts := []string{ + fmt.Sprintf("declared=%s", normalizeStatusPolicy(desc.DeclaredPolicy)), + fmt.Sprintf("effective=%s", normalizeStatusPolicy(effective)), + fmt.Sprintf("display=%s", display), + fmt.Sprintf("ready=%t", desc.Ready), + fmt.Sprintf("source=%s", source), + } + if desc.ReadyError != nil { + parts = append(parts, fmt.Sprintf("error=%s", strings.TrimSpace(desc.ReadyError.Error()))) + } + + return strings.Join(parts, " ") +} + +func summarizePreflightFailure(err error) (string, string) { + if err == nil { + return "secret backend is unavailable", "" + } + + switch { + case errors.Is(err, ErrBWNotLoggedIn): + return "bitwarden login is required", strings.TrimSpace(err.Error()) + case errors.Is(err, ErrBWLocked): + return "bitwarden vault is locked or BW_SESSION is missing", strings.TrimSpace(err.Error()) + case errors.Is(err, ErrBWUnavailable): + return "bitwarden CLI is unavailable", strings.TrimSpace(err.Error()) + case errors.Is(err, ErrBackendUnavailable): + return "secret backend is unavailable", strings.TrimSpace(err.Error()) + default: + return "secret backend preflight failed", strings.TrimSpace(err.Error()) + } +} + +func manifestSourceLabel(source string) string { + trimmed := strings.TrimSpace(source) + if trimmed == "" { + return DefaultManifestSource + } + return trimmed +} + +func normalizeStatusPolicy(policy BackendPolicy) string { + trimmed := strings.TrimSpace(string(policy)) + if trimmed == "" { + return string(BackendAuto) + } + return trimmed +} diff --git a/secretstore/runtime_test.go b/secretstore/runtime_test.go new file mode 100644 index 0000000..3daf05b --- /dev/null +++ b/secretstore/runtime_test.go @@ -0,0 +1,155 @@ +package secretstore + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) { + desc, err := DescribeRuntime(DescribeRuntimeOptions{ + ServiceName: "graylog-mcp", + LookupEnv: func(string) (string, bool) { return "", false }, + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)}, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("DescribeRuntime returned error: %v", err) + } + + if desc.ManifestSource == "" { + t.Fatal("ManifestSource should not be empty") + } + if desc.DeclaredPolicy != BackendEnvOnly { + t.Fatalf("DeclaredPolicy = %q, want %q", desc.DeclaredPolicy, BackendEnvOnly) + } + if desc.EffectivePolicy != BackendEnvOnly { + t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendEnvOnly) + } + if desc.DisplayName == "" { + t.Fatal("DisplayName should not be empty") + } + if !desc.Ready { + t.Fatalf("Ready = %v, want true", desc.Ready) + } + if desc.ReadyError != nil { + t.Fatalf("ReadyError = %v, want nil", desc.ReadyError) + } +} + +func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + switch { + case len(args) == 1 && args[0] == "--version": + return []byte("2026.1.0\n"), nil + case len(args) == 1 && args[0] == "status": + return []byte(`{"status":"locked"}`), nil + default: + return nil, errors.New("unexpected bitwarden invocation") + } + }) + + desc, err := DescribeRuntime(DescribeRuntimeOptions{ + ServiceName: "graylog-mcp", + Shell: "fish", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)}, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("DescribeRuntime returned error: %v", err) + } + + if desc.DeclaredPolicy != BackendBitwardenCLI { + t.Fatalf("DeclaredPolicy = %q, want %q", desc.DeclaredPolicy, BackendBitwardenCLI) + } + if desc.EffectivePolicy != BackendBitwardenCLI { + t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendBitwardenCLI) + } + if desc.Ready { + t.Fatalf("Ready = %v, want false", desc.Ready) + } + if !errors.Is(desc.ReadyError, ErrBWLocked) { + t.Fatalf("ReadyError = %v, want ErrBWLocked", desc.ReadyError) + } + if !strings.Contains(desc.ReadyError.Error(), "set -x BW_SESSION (bw unlock --raw)") { + t.Fatalf("ReadyError = %v, want fish remediation", desc.ReadyError) + } +} + +func TestPreflightFromManifestReturnsTypedStatusAndRemediation(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + switch { + case len(args) == 1 && args[0] == "--version": + return []byte("2026.1.0\n"), nil + case len(args) == 1 && args[0] == "status": + return []byte(`{"status":"locked"}`), nil + default: + return nil, errors.New("unexpected bitwarden invocation") + } + }) + + report, err := PreflightFromManifest(PreflightOptions{ + ServiceName: "graylog-mcp", + Shell: "fish", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)}, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("PreflightFromManifest returned error: %v", err) + } + + if report.Status != PreflightStatusFail { + t.Fatalf("Status = %q, want %q", report.Status, PreflightStatusFail) + } + if !strings.Contains(strings.ToLower(report.Summary), "locked") { + t.Fatalf("Summary = %q, want lock hint", report.Summary) + } + if !strings.Contains(report.Remediation, "set -x BW_SESSION (bw unlock --raw)") { + t.Fatalf("Remediation = %q, want fish remediation", report.Remediation) + } +} + +func TestFormatBackendStatusIncludesDeclaredEffectiveAndReadiness(t *testing.T) { + line := FormatBackendStatus(RuntimeDescription{ + ManifestSource: "/opt/graylog-mcp/mcp.toml", + DeclaredPolicy: BackendBitwardenCLI, + EffectivePolicy: BackendBitwardenCLI, + DisplayName: "Bitwarden CLI", + Ready: false, + ReadyError: ErrBWLocked, + }) + + for _, needle := range []string{ + "declared=bitwarden-cli", + "effective=bitwarden-cli", + "display=Bitwarden CLI", + "ready=false", + "source=/opt/graylog-mcp/mcp.toml", + "error=", + } { + if !strings.Contains(line, needle) { + t.Fatalf("line = %q, want substring %q", line, needle) + } + } +} diff --git a/secretstore/store.go b/secretstore/store.go index 2b9bb5f..7b1b787 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -37,6 +37,7 @@ type Options struct { KWalletAppID string KWalletFolder string BitwardenCommand string + Shell string } type Store interface { -- 2.45.2 From 98f07f557db3b6b8ace4e9dd6cccee8167d621d1 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 11:18:14 +0200 Subject: [PATCH 35/79] feat(secretstore): add animated bitwarden wait loader --- secretstore/bitwarden.go | 116 ++++++++++++++++++++++++++++++++++ secretstore/bitwarden_test.go | 39 ++++++++++++ 2 files changed, 155 insertions(+) diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index ffcc0f7..acb7f27 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -10,6 +10,9 @@ import ( "path/filepath" "runtime" "strings" + "sync" + "sync/atomic" + "time" ) const ( @@ -18,11 +21,19 @@ const ( bitwardenSecretFieldName = "mcp-secret" bitwardenServiceFieldName = "mcp-service" bitwardenSecretNameFieldName = "mcp-secret-name" + bitwardenLoaderMessage = "Waiting BitWarden..." + bitwardenLoaderInterval = 90 * time.Millisecond + bitwardenLoaderColorBase = "\033[38;5;39m" + bitwardenLoaderColorWave = "\033[38;5;81m" + bitwardenLoaderColorFocus = "\033[38;5;117m" + bitwardenLoaderResetColor = "\033[0m" + bitwardenLoaderClearLine = "\r\033[2K" ) type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error) var runBitwardenCLI bitwardenRunner = executeBitwardenCLI +var bitwardenLoaderActive atomic.Bool type bitwardenStore struct { command string @@ -511,6 +522,9 @@ func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName } func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { + stopLoader := startBitwardenLoader() + defer stopLoader() + cmd := exec.Command(command, args...) if stdin != nil { cmd.Stdin = bytes.NewReader(stdin) @@ -528,6 +542,108 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, return stdout.Bytes(), nil } +func startBitwardenLoader() func() { + if !shouldShowBitwardenLoader() { + return func() {} + } + if !bitwardenLoaderActive.CompareAndSwap(false, true) { + return func() {} + } + + done := make(chan struct{}) + stopped := make(chan struct{}) + + go func() { + defer close(stopped) + + ticker := time.NewTicker(bitwardenLoaderInterval) + defer ticker.Stop() + + phase := 0 + for { + _, _ = fmt.Fprint(os.Stdout, bitwardenLoaderFrame(phase)) + phase++ + + select { + case <-done: + _, _ = fmt.Fprint(os.Stdout, bitwardenLoaderClearLine) + return + case <-ticker.C: + } + } + }() + + var stopOnce sync.Once + return func() { + stopOnce.Do(func() { + close(done) + <-stopped + bitwardenLoaderActive.Store(false) + }) + } +} + +func shouldShowBitwardenLoader() bool { + term := strings.TrimSpace(os.Getenv("TERM")) + if term == "" || strings.EqualFold(term, "dumb") { + return false + } + + info, err := os.Stdout.Stat() + if err != nil { + return false + } + + return (info.Mode() & os.ModeCharDevice) != 0 +} + +func bitwardenLoaderFrame(phase int) string { + chars := []rune(bitwardenLoaderMessage) + if len(chars) == 0 { + return bitwardenLoaderClearLine + } + + waveIndex := phase % len(chars) + if waveIndex < 0 { + waveIndex += len(chars) + } + + var frame strings.Builder + frame.Grow(len(chars)*14 + len(bitwardenLoaderClearLine) + len(bitwardenLoaderResetColor)) + frame.WriteString(bitwardenLoaderClearLine) + + for idx, char := range chars { + frame.WriteString(bitwardenLoaderColorForIndex(idx, waveIndex, len(chars))) + frame.WriteRune(char) + } + + frame.WriteString(bitwardenLoaderResetColor) + return frame.String() +} + +func bitwardenLoaderColorForIndex(idx, waveIndex, length int) string { + if length <= 1 { + return bitwardenLoaderColorFocus + } + + distance := idx - waveIndex + if distance < 0 { + distance = -distance + } + if wrapped := length - distance; wrapped < distance { + distance = wrapped + } + + switch distance { + case 0: + return bitwardenLoaderColorFocus + case 1: + return bitwardenLoaderColorWave + default: + return bitwardenLoaderColorBase + } +} + func normalizeBitwardenExecutionError(err error, stderrText, stdoutText string) error { detail := sanitizeBitwardenErrorDetail(stderrText, stdoutText) classification := classifyBitwardenError(detail) diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index e12a662..9a3e7e6 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -7,8 +7,10 @@ import ( "fmt" "os/exec" "path/filepath" + "regexp" "strings" "testing" + "unicode/utf8" "gitea.lclr.dev/AI/mcp-framework/manifest" ) @@ -365,6 +367,43 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) { } } +func TestBitwardenLoaderFrameUsesSingleLineRewriteAndMessage(t *testing.T) { + frame := bitwardenLoaderFrame(0) + if !strings.HasPrefix(frame, "\r\033[2K") { + t.Fatalf("frame prefix = %q, want carriage return + clear line", frame) + } + if !strings.Contains(frame, "\033[38;5;117mW") { + t.Fatalf("frame = %q, want highlighted first rune", frame) + } + cleaned := stripANSIControlSequences(frame) + if cleaned != bitwardenLoaderMessage { + t.Fatalf("cleaned frame = %q, want %q", cleaned, bitwardenLoaderMessage) + } +} + +func TestBitwardenLoaderFrameMovesAndWrapsTheWave(t *testing.T) { + firstFrame := bitwardenLoaderFrame(0) + secondFrame := bitwardenLoaderFrame(1) + if firstFrame == secondFrame { + t.Fatal("expected different frames between two ticks") + } + if !strings.Contains(secondFrame, "\033[38;5;117ma") { + t.Fatalf("second frame = %q, want highlighted second rune", secondFrame) + } + + wrapped := bitwardenLoaderFrame(utf8.RuneCountInString(bitwardenLoaderMessage)) + if !strings.Contains(wrapped, "\033[38;5;117mW") { + t.Fatalf("wrapped frame = %q, want wave to wrap to first rune", wrapped) + } +} + +var ansiControlSequencePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +func stripANSIControlSequences(value string) string { + noANSI := ansiControlSequencePattern.ReplaceAllString(value, "") + return strings.ReplaceAll(noANSI, "\r", "") +} + func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") -- 2.45.2 From 98bac84ab82097ddb22c67b7984ba0b81dcec58b Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 11:36:07 +0200 Subject: [PATCH 36/79] feat: add --debug tracing for bitwarden calls --- bootstrap/bootstrap.go | 36 +++++++++++++++- bootstrap/bootstrap_test.go | 71 ++++++++++++++++++++++++++++++++ cli/doctor.go | 2 + docs/bootstrap-cli.md | 2 + docs/secrets.md | 4 ++ secretstore/bitwarden.go | 77 ++++++++++++++++++++++++++++++++++- secretstore/bitwarden_test.go | 65 +++++++++++++++++++++++++++++ secretstore/manifest_open.go | 2 + secretstore/runtime.go | 2 + secretstore/store.go | 1 + 10 files changed, 259 insertions(+), 3 deletions(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index ea2ff2f..533952f 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -18,6 +18,8 @@ const ( CommandVersion = "version" CommandDoctor = "doctor" + bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG" + ConfigSubcommandShow = "show" ConfigSubcommandTest = "test" ConfigSubcommandDelete = "delete" @@ -118,7 +120,13 @@ func Run(ctx context.Context, opts Options) error { return ErrBinaryNameRequired } - command, commandArgs, showHelp := parseArgs(expandAliases(normalized.Args, normalized.Aliases)) + resolvedArgs := expandAliases(normalized.Args, normalized.Aliases) + resolvedArgs, debugEnabled := extractGlobalDebugFlag(resolvedArgs) + if debugEnabled { + _ = os.Setenv(bitwardenDebugEnvName, "1") + } + + command, commandArgs, showHelp := parseArgs(resolvedArgs) if showHelp { return printHelp(normalized, command, commandArgs) } @@ -252,6 +260,25 @@ func parseArgs(args []string) (command string, commandArgs []string, showHelp bo return command, commandArgs, false } +func extractGlobalDebugFlag(args []string) ([]string, bool) { + args = trimArgs(args) + if len(args) == 0 { + return nil, false + } + + filtered := make([]string, 0, len(args)) + debugEnabled := false + for _, arg := range args { + if strings.TrimSpace(arg) == "--debug" { + debugEnabled = true + continue + } + filtered = append(filtered, arg) + } + + return filtered, debugEnabled +} + func expandAliases(args []string, aliases map[string][]string) []string { args = trimArgs(args) if len(args) == 0 || len(aliases) == 0 { @@ -512,6 +539,13 @@ func printGlobalHelp(opts Options) error { } } + if _, err := fmt.Fprintln(opts.Stdout, "\nOptions globales:"); err != nil { + return err + } + if _, err := fmt.Fprintln(opts.Stdout, " --debug Active le debug des appels Bitwarden."); err != nil { + return err + } + _, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help \n", opts.BinaryName) return err } diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index 02644e3..5e1f899 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "os" "slices" "strings" "testing" @@ -482,3 +483,73 @@ func TestRunPrintsDoctorAliasInGlobalHelpWhenEnabled(t *testing.T) { t.Fatalf("global help output missing default doctor alias details: %q", text) } } + +func TestRunAcceptsGlobalDebugFlagAndRoutesCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + var got Invocation + + t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "") + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"--debug", "setup", "--profile", "prod"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Setup: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != CommandSetup { + t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup) + } + wantArgs := []string{"--profile", "prod"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } + if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" { + t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1") + } +} + +func TestRunAcceptsGlobalDebugFlagAfterCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + var got Invocation + + t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "") + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"setup", "--debug", "--profile", "prod"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Setup: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != CommandSetup { + t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup) + } + wantArgs := []string{"--profile", "prod"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } + if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" { + t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1") + } +} diff --git a/cli/doctor.go b/cli/doctor.go index 293806e..10a508b 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -65,6 +65,7 @@ type DoctorOptions struct { type BitwardenDoctorOptions struct { Command string + Debug bool Shell string LookupEnv func(string) (string, bool) } @@ -238,6 +239,7 @@ func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck { return func(context.Context) DoctorResult { err := checkBitwardenReady(secretstore.Options{ BitwardenCommand: strings.TrimSpace(options.Command), + BitwardenDebug: options.Debug, Shell: strings.TrimSpace(options.Shell), LookupEnv: options.LookupEnv, }) diff --git a/docs/bootstrap-cli.md b/docs/bootstrap-cli.md index 3bce155..74695ff 100644 --- a/docs/bootstrap-cli.md +++ b/docs/bootstrap-cli.md @@ -43,3 +43,5 @@ Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automati Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. +Le flag global `--debug` est supporté et active le debug des appels Bitwarden +(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`). diff --git a/docs/secrets.md b/docs/secrets.md index 24046d1..c1f5ee2 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -164,6 +164,10 @@ fmt.Println(report.Remediation) // action recommandée ## Debug Bitwarden en 60 secondes +Tu peux activer les traces d'appels Bitwarden avec le flag CLI global `--debug` +(via `bootstrap`) ou en exportant `MCP_FRAMEWORK_BITWARDEN_DEBUG=1`. +Les commandes `bw` exécutées seront affichées (avec redaction des payloads sensibles). + 1. Vérifier l'état de session : ```bash diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index acb7f27..4d0b6f6 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -17,6 +18,7 @@ import ( const ( defaultBitwardenCommand = "bw" + bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG" bitwardenSessionEnvName = "BW_SESSION" bitwardenSecretFieldName = "mcp-secret" bitwardenServiceFieldName = "mcp-service" @@ -34,10 +36,12 @@ type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, var runBitwardenCLI bitwardenRunner = executeBitwardenCLI var bitwardenLoaderActive atomic.Bool +var bitwardenDebugOutput io.Writer = os.Stderr type bitwardenStore struct { command string serviceName string + debug bool } type bitwardenListItem struct { @@ -54,10 +58,12 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string if command == "" { command = defaultBitwardenCommand } + debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) store := &bitwardenStore{ command: command, serviceName: serviceName, + debug: debugEnabled, } if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { @@ -80,6 +86,7 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string if err := EnsureBitwardenReady(Options{ BitwardenCommand: command, + BitwardenDebug: debugEnabled, LookupEnv: options.LookupEnv, Shell: options.Shell, }); err != nil { @@ -99,6 +106,7 @@ func EnsureBitwardenReady(options Options) error { if command == "" { command = defaultBitwardenCommand } + debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) unlockCommand := bitwardenUnlockRemediation(command, options.Shell) lookupEnv := options.LookupEnv @@ -106,7 +114,7 @@ func EnsureBitwardenReady(options Options) error { lookupEnv = os.LookupEnv } - output, err := runBitwardenCLI(command, nil, "status") + output, err := runBitwardenCommand(command, debugEnabled, nil, "status") if err != nil { return fmt.Errorf("check bitwarden CLI status: %w", err) } @@ -438,7 +446,7 @@ func (s *bitwardenStore) encodePayload(payload map[string]any) (string, error) { } func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) { - output, err := runBitwardenCLI(s.command, stdin, args...) + output, err := runBitwardenCommand(s.command, s.debug, stdin, args...) if err != nil { return nil, fmt.Errorf("%s: %w", operation, err) } @@ -521,6 +529,71 @@ func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName strings.TrimSpace(markedSecretName) == strings.TrimSpace(secretName) } +func runBitwardenCommand(command string, debug bool, stdin []byte, args ...string) ([]byte, error) { + if debug { + logBitwardenCommand(command, args...) + } + return runBitwardenCLI(command, stdin, args...) +} + +func isBitwardenDebugEnabled(explicit bool) bool { + if explicit { + return true + } + + raw, ok := os.LookupEnv(bitwardenDebugEnvName) + if !ok { + return false + } + + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} + +func logBitwardenCommand(command string, args ...string) { + writer := bitwardenDebugOutput + if writer == nil { + return + } + + renderedArgs := sanitizeBitwardenDebugArgs(args) + if len(renderedArgs) == 0 { + _, _ = fmt.Fprintf(writer, "[bitwarden debug] %s\n", strings.TrimSpace(command)) + return + } + + _, _ = fmt.Fprintf( + writer, + "[bitwarden debug] %s %s\n", + strings.TrimSpace(command), + strings.Join(renderedArgs, " "), + ) +} + +func sanitizeBitwardenDebugArgs(args []string) []string { + if len(args) == 0 { + return nil + } + + rendered := make([]string, len(args)) + for idx, arg := range args { + rendered[idx] = strings.TrimSpace(arg) + } + + if len(rendered) >= 3 && rendered[0] == "create" && rendered[1] == "item" { + rendered[2] = "" + } + if len(rendered) >= 4 && rendered[0] == "edit" && rendered[1] == "item" { + rendered[3] = "" + } + + return rendered +} + func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { stopLoader := startBitwardenLoader() defer stopLoader() diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 9a3e7e6..b71e832 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -1,10 +1,12 @@ package secretstore import ( + "bytes" "encoding/base64" "encoding/json" "errors" "fmt" + "io" "os/exec" "path/filepath" "regexp" @@ -367,6 +369,59 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) { } } +func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1") + + var logs bytes.Buffer + withBitwardenDebugOutput(t, &logs) + + _, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + text := logs.String() + if !strings.Contains(text, "bw --version") { + t.Fatalf("debug logs = %q, want command bw --version", text) + } + if !strings.Contains(text, "bw status") { + t.Fatalf("debug logs = %q, want command bw status", text) + } +} + +func TestBitwardenDebugRedactsSensitivePayloadArguments(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + var logs bytes.Buffer + withBitwardenDebugOutput(t, &logs) + + store, err := Open(Options{ + ServiceName: "graylog-mcp", + BackendPolicy: BackendBitwardenCLI, + BitwardenDebug: true, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + + text := logs.String() + if !strings.Contains(text, "bw create item ") { + t.Fatalf("debug logs = %q, want redacted create payload", text) + } +} + func TestBitwardenLoaderFrameUsesSingleLineRewriteAndMessage(t *testing.T) { frame := bitwardenLoaderFrame(0) if !strings.HasPrefix(frame, "\r\033[2K") { @@ -455,6 +510,16 @@ func withBitwardenRunner( }) } +func withBitwardenDebugOutput(t *testing.T, writer io.Writer) { + t.Helper() + + previous := bitwardenDebugOutput + bitwardenDebugOutput = writer + t.Cleanup(func() { + bitwardenDebugOutput = previous + }) +} + type fakeBitwardenCLI struct { command string itemsByID map[string]fakeBitwardenItem diff --git a/secretstore/manifest_open.go b/secretstore/manifest_open.go index 9c95653..f8009d4 100644 --- a/secretstore/manifest_open.go +++ b/secretstore/manifest_open.go @@ -20,6 +20,7 @@ type OpenFromManifestOptions struct { KWalletAppID string KWalletFolder string BitwardenCommand string + BitwardenDebug bool Shell string ManifestLoader ManifestLoader ExecutableResolver ExecutableResolver @@ -38,6 +39,7 @@ func OpenFromManifest(options OpenFromManifestOptions) (Store, error) { KWalletAppID: options.KWalletAppID, KWalletFolder: options.KWalletFolder, BitwardenCommand: strings.TrimSpace(options.BitwardenCommand), + BitwardenDebug: options.BitwardenDebug, Shell: strings.TrimSpace(options.Shell), }) } diff --git a/secretstore/runtime.go b/secretstore/runtime.go index ec724dc..4d342d7 100644 --- a/secretstore/runtime.go +++ b/secretstore/runtime.go @@ -14,6 +14,7 @@ type DescribeRuntimeOptions struct { KWalletAppID string KWalletFolder string BitwardenCommand string + BitwardenDebug bool Shell string ManifestLoader ManifestLoader ExecutableResolver ExecutableResolver @@ -73,6 +74,7 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) KWalletAppID: options.KWalletAppID, KWalletFolder: options.KWalletFolder, BitwardenCommand: options.BitwardenCommand, + BitwardenDebug: options.BitwardenDebug, Shell: options.Shell, }) if openErr != nil { diff --git a/secretstore/store.go b/secretstore/store.go index 7b1b787..5d8d2a9 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -37,6 +37,7 @@ type Options struct { KWalletAppID string KWalletFolder string BitwardenCommand string + BitwardenDebug bool Shell string } -- 2.45.2 From 2920f5980ac77420b987ee1e3c0e2c1b191011ec Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 12:03:22 +0200 Subject: [PATCH 37/79] perf: reduce redundant bitwarden CLI calls --- scaffold/scaffold.go | 26 ++++++++++++++-- scaffold/scaffold_test.go | 2 ++ secretstore/bitwarden.go | 56 +++++++++++++++++------------------ secretstore/bitwarden_test.go | 36 +++++++++++++++++++++- 4 files changed, 89 insertions(+), 31 deletions(-) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 1856f3c..2d54d99 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -1537,6 +1537,7 @@ import ( "os" "path/filepath" "strings" + "sync" "gitea.lclr.dev/AI/mcp-framework/bootstrap" "gitea.lclr.dev/AI/mcp-framework/cli" @@ -1816,14 +1817,16 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er stdout = os.Stdout } + secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore) + report := cli.RunDoctor(ctx, cli.DoctorOptions{ ConfigCheck: cli.NewConfigCheck(r.ConfigStore), - SecretStoreCheck: cli.SecretStoreAvailabilityCheck(r.openSecretStore), + SecretStoreCheck: cli.SecretStoreAvailabilityCheck(secretStoreFactory), SecretBackendPolicy: r.activeBackendPolicy(), RequiredSecrets: []cli.DoctorSecret{ {Name: r.SecretName, Label: "API token"}, }, - SecretStoreFactory: r.openSecretStore, + SecretStoreFactory: secretStoreFactory, ManifestCheck: r.manifestDoctorCheck(), BitwardenOptions: cli.BitwardenDoctorOptions{ LookupEnv: os.LookupEnv, @@ -1841,6 +1844,25 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er return nil } +func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) { + if factory == nil { + return nil + } + + var ( + once sync.Once + store secretstore.Store + err error + ) + + return func() (secretstore.Store, error) { + once.Do(func() { + store, err = factory() + }) + return store, err + } +} + func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error { stdout := inv.Stdout if stdout == nil { diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 5880090..2e351eb 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -76,6 +76,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "ManifestSource", "ManifestCheck: r.manifestDoctorCheck()", "SecretBackendPolicy: r.activeBackendPolicy()", + "secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)", + "func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {", "cli.WriteSetupSecretVerified", } { if !strings.Contains(string(appGo), snippet) { diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 4d0b6f6..439b146 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -211,7 +211,7 @@ func detectShellFlavor(shellHint string) string { func (s *bitwardenStore) SetSecret(name, label, secret string) error { secretName := s.scopedName(name) - item, err := s.findItem(secretName, name) + item, payload, err := s.findItem(secretName, name) switch { case errors.Is(err, ErrNotFound): template, err := s.itemTemplate() @@ -239,11 +239,6 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { return err } - payload, err := s.itemByID(item.ID) - if err != nil { - return err - } - setBitwardenSecretPayload(payload, s.serviceName, name, secretName, label, secret) encoded, err := s.encodePayload(payload) if err != nil { @@ -266,12 +261,7 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { func (s *bitwardenStore) GetSecret(name string) (string, error) { secretName := s.scopedName(name) - item, err := s.findItem(secretName, name) - if err != nil { - return "", err - } - - payload, err := s.itemByID(item.ID) + _, payload, err := s.findItem(secretName, name) if err != nil { return "", err } @@ -286,7 +276,7 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) { func (s *bitwardenStore) DeleteSecret(name string) error { secretName := s.scopedName(name) - item, err := s.findItem(secretName, name) + item, _, err := s.findItem(secretName, name) if errors.Is(err, ErrNotFound) { return nil } @@ -311,7 +301,12 @@ func (s *bitwardenStore) scopedName(name string) string { return fmt.Sprintf("%s/%s", s.serviceName, name) } -func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenListItem, error) { +type bitwardenResolvedItem struct { + item bitwardenListItem + payload map[string]any +} + +func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenListItem, map[string]any, error) { output, err := s.execute( fmt.Sprintf("list bitwarden items for secret %q", secretName), nil, @@ -321,15 +316,15 @@ func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenLi secretName, ) if err != nil { - return bitwardenListItem{}, err + return bitwardenListItem{}, nil, err } if strings.TrimSpace(string(output)) == "" { - return bitwardenListItem{}, ErrNotFound + return bitwardenListItem{}, nil, ErrNotFound } var items []bitwardenListItem if err := json.Unmarshal(output, &items); err != nil { - return bitwardenListItem{}, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err) + return bitwardenListItem{}, nil, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err) } matches := make([]bitwardenListItem, 0, len(items)) @@ -344,42 +339,47 @@ func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenLi } if len(matches) == 0 { - return bitwardenListItem{}, ErrNotFound + return bitwardenListItem{}, nil, ErrNotFound } - markedMatches := make([]bitwardenListItem, 0, len(matches)) - legacyMatches := make([]bitwardenListItem, 0, len(matches)) + markedMatches := make([]bitwardenResolvedItem, 0, len(matches)) + legacyMatches := make([]bitwardenResolvedItem, 0, len(matches)) for _, item := range matches { payload, err := s.itemByID(item.ID) if err != nil { - return bitwardenListItem{}, err + return bitwardenListItem{}, nil, err + } + + resolved := bitwardenResolvedItem{ + item: item, + payload: payload, } if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) { - markedMatches = append(markedMatches, item) + markedMatches = append(markedMatches, resolved) continue } - legacyMatches = append(legacyMatches, item) + legacyMatches = append(legacyMatches, resolved) } switch len(markedMatches) { case 0: switch len(legacyMatches) { case 0: - return bitwardenListItem{}, ErrNotFound + return bitwardenListItem{}, nil, ErrNotFound case 1: - return legacyMatches[0], nil + return legacyMatches[0].item, legacyMatches[0].payload, nil default: - return bitwardenListItem{}, fmt.Errorf( + return bitwardenListItem{}, nil, fmt.Errorf( "multiple legacy bitwarden items match secret %q for service %q", secretName, s.serviceName, ) } case 1: - return markedMatches[0], nil + return markedMatches[0].item, markedMatches[0].payload, nil default: - return bitwardenListItem{}, fmt.Errorf( + return bitwardenListItem{}, nil, fmt.Errorf( "multiple bitwarden items share marker for secret %q and service %q", secretName, s.serviceName, diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index b71e832..5da8fe2 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -337,6 +337,38 @@ func TestBitwardenStoreFallsBackToSingleLegacyItemWithoutMarkers(t *testing.T) { } } +func TestBitwardenStoreGetSecretReadsSelectedItemOnlyOnce(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret = %q, want secret-v1", value) + } + if fakeCLI.getItemCalls != 1 { + t.Fatalf("bw get item count = %d, want 1", fakeCLI.getItemCalls) + } +} + func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) { store := &bitwardenStore{command: "bw", serviceName: "email-mcp"} withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { @@ -346,7 +378,7 @@ func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) { return nil, fmt.Errorf("unexpected args: %v", args) }) - _, err := store.findItem("email-mcp/api-token", "api-token") + _, _, err := store.findItem("email-mcp/api-token", "api-token") if !errors.Is(err, ErrNotFound) { t.Fatalf("error = %v, want ErrNotFound", err) } @@ -527,6 +559,7 @@ type fakeBitwardenCLI struct { status string versionChecked bool statusChecked bool + getItemCalls int } type fakeBitwardenItem struct { @@ -568,6 +601,7 @@ func (f *fakeBitwardenCLI) run(command string, stdin []byte, args ...string) ([] case len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search": return f.handleListItems(args[3]) case len(args) == 3 && args[0] == "get" && args[1] == "item": + f.getItemCalls++ return f.handleGetItem(args[2]) case len(args) == 3 && args[0] == "get" && args[1] == "template" && args[2] == "item": return []byte(`{"type":2,"name":"","notes":"","secureNote":{"type":0},"fields":[]}`), nil -- 2.45.2 From 6e80d3418ec6dc2a6d5289c49ee8943b2598db10 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 12:38:58 +0200 Subject: [PATCH 38/79] feat: add bitwarden login flow with persisted BW_SESSION --- bootstrap/bootstrap.go | 9 + bootstrap/bootstrap_test.go | 54 ++++++ docs/bootstrap-cli.md | 4 + docs/packages.md | 2 +- docs/secrets.md | 30 +++- scaffold/scaffold.go | 48 +++++- scaffold/scaffold_test.go | 4 + secretstore/bitwarden.go | 116 ++++++++++--- secretstore/bitwarden_session.go | 197 ++++++++++++++++++++++ secretstore/bitwarden_session_test.go | 231 ++++++++++++++++++++++++++ 10 files changed, 673 insertions(+), 22 deletions(-) create mode 100644 secretstore/bitwarden_session.go create mode 100644 secretstore/bitwarden_session_test.go diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 533952f..13fdd57 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -12,6 +12,7 @@ import ( const ( CommandSetup = "setup" + CommandLogin = "login" CommandMCP = "mcp" CommandConfig = "config" CommandUpdate = "update" @@ -38,6 +39,7 @@ type Handler func(context.Context, Invocation) error type Hooks struct { Setup Handler + Login Handler MCP Handler Config Handler ConfigShow Handler @@ -83,6 +85,13 @@ var commands = []commandDef{ return h.Setup }, }, + { + Name: CommandLogin, + Description: "Authentifier et deverrouiller Bitwarden pour persister BW_SESSION.", + Handler: func(h Hooks) Handler { + return h.Login + }, + }, { Name: CommandMCP, Description: "Executer la logique MCP principale du binaire.", diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index 5e1f899..ca2c34a 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -41,6 +41,37 @@ func TestRunRoutesSetupHook(t *testing.T) { } } +func TestRunRoutesLoginHook(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + var got Invocation + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Version: "v1.2.3", + Args: []string{"login", "--force"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Login: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != CommandLogin { + t.Fatalf("invocation command = %q, want %q", got.Command, CommandLogin) + } + wantArgs := []string{"--force"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } +} + func TestRunReturnsUnknownCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -215,6 +246,29 @@ func TestRunPrintsCommandHelp(t *testing.T) { } } +func TestRunPrintsLoginCommandHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"help", "login"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp login [args]") { + t.Fatalf("command help output = %q", text) + } + if !strings.Contains(strings.ToLower(text), "bitwarden") { + t.Fatalf("command help output missing bitwarden description: %q", text) + } +} + func TestRunPrintsConfigHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/docs/bootstrap-cli.md b/docs/bootstrap-cli.md index 74695ff..403de43 100644 --- a/docs/bootstrap-cli.md +++ b/docs/bootstrap-cli.md @@ -18,6 +18,9 @@ func main() { Setup: func(ctx context.Context, inv bootstrap.Invocation) error { return runSetup(ctx, inv.Args) }, + Login: func(ctx context.Context, inv bootstrap.Invocation) error { + return runLogin(ctx, inv.Args) + }, MCP: func(ctx context.Context, inv bootstrap.Invocation) error { return runMCP(ctx, inv.Args) }, @@ -41,6 +44,7 @@ func main() { Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`. Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). +La commande `login` est optionnelle et peut être branchée pour gérer un unlock Bitwarden interactif. La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. Le flag global `--debug` est supporté et active le debug des appels Bitwarden diff --git a/docs/packages.md b/docs/packages.md index 1e197e2..beec9cb 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -1,6 +1,6 @@ # Packages -- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. +- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `login`, `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, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. diff --git a/docs/secrets.md b/docs/secrets.md index c1f5ee2..07d527e 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -91,6 +91,34 @@ if err := secretstore.EnsureBitwardenReady(secretstore.Options{ } ``` +Pour lancer un flux interactif `bw login` / `bw unlock --raw`, récupérer `BW_SESSION` +et le persister localement (fichier `0600` sous le répertoire de config utilisateur) : + +```go +session, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ + ServiceName: "email-mcp", + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, +}) +if err != nil { + return err +} +fmt.Println("session chargée:", len(session) > 0) +``` + +Pour réinjecter automatiquement une session persistée dans l'environnement courant : + +```go +loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ + ServiceName: "email-mcp", +}) +if err != nil { + return err +} +fmt.Println("session restaurée depuis disque:", loaded) +``` + Pour stocker un secret structuré en JSON : ```go @@ -174,7 +202,7 @@ Les commandes `bw` exécutées seront affichées (avec redaction des payloads se bw status ``` -2. Déverrouiller le vault et exporter `BW_SESSION` : +2. Déverrouiller le vault et exporter `BW_SESSION` (ou utiliser `LoginBitwarden`) : - Bash/Zsh : diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 2d54d99..9723a58 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -1659,6 +1659,7 @@ func (r Runtime) Run(ctx context.Context, args []string) error { Args: args, Hooks: bootstrap.Hooks{ Setup: r.runSetup, + Login: r.runLogin, MCP: r.runMCP, ConfigShow: r.runConfigShow, ConfigTest: r.runConfigTest, @@ -1751,6 +1752,42 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { return err } +func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error { + if r.activeBackendPolicy() != secretstore.BackendBitwardenCLI { + return fmt.Errorf( + "commande login disponible uniquement avec secret_store.backend_policy=%q", + secretstore.BackendBitwardenCLI, + ) + } + + stdin := inv.Stdin + if stdin == nil { + stdin = os.Stdin + } + + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + stderr := inv.Stderr + if stderr == nil { + stderr = os.Stderr + } + + if _, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ + ServiceName: r.BinaryName, + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + }); err != nil { + return err + } + + _, err := fmt.Fprintf(stdout, "Session Bitwarden persistée pour %q.\n", r.BinaryName) + return err +} + func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error { stdout := inv.Stdout if stdout == nil { @@ -1878,9 +1915,18 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error } func (r Runtime) openSecretStore() (secretstore.Store, error) { + policy := r.activeBackendPolicy() + if policy == secretstore.BackendBitwardenCLI { + if _, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ + ServiceName: r.BinaryName, + }); err != nil { + return nil, err + } + } + return secretstore.Open(secretstore.Options{ ServiceName: r.BinaryName, - BackendPolicy: r.activeBackendPolicy(), + BackendPolicy: policy, LookupEnv: func(name string) (string, bool) { if name == r.SecretName { return os.LookupEnv(r.TokenEnv) diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 2e351eb..d67353f 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -67,6 +67,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "config.NewStore[Profile]", "secretstore.Open(secretstore.Options", + "secretstore.EnsureBitwardenSessionEnv", + "secretstore.LoginBitwarden", "update.Run", "manifest.LoadDefaultOrEmbedded", "bootstrap.Run", @@ -76,6 +78,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "ManifestSource", "ManifestCheck: r.manifestDoctorCheck()", "SecretBackendPolicy: r.activeBackendPolicy()", + "Login: r.runLogin,", + "func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error {", "secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)", "func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {", "cli.WriteSetupSecretVerified", diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 439b146..25765ca 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -33,8 +33,15 @@ const ( ) type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error) +type bitwardenInteractiveRunner func( + command string, + stdin io.Reader, + stdout, stderr io.Writer, + args ...string, +) ([]byte, error) var runBitwardenCLI bitwardenRunner = executeBitwardenCLI +var runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive var bitwardenLoaderActive atomic.Bool var bitwardenDebugOutput io.Writer = os.Stderr @@ -66,6 +73,17 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string debug: debugEnabled, } + if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ + ServiceName: serviceName, + }); err != nil { + return nil, fmt.Errorf( + "secret backend policy %q cannot load persisted bitwarden session for service %q: %w", + policy, + serviceName, + errors.Join(ErrBackendUnavailable, err), + ) + } + if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { if errors.Is(err, exec.ErrNotFound) { return nil, fmt.Errorf( @@ -109,27 +127,12 @@ func EnsureBitwardenReady(options Options) error { debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) unlockCommand := bitwardenUnlockRemediation(command, options.Shell) - lookupEnv := options.LookupEnv - if lookupEnv == nil { - lookupEnv = os.LookupEnv - } - - output, err := runBitwardenCommand(command, debugEnabled, nil, "status") + status, err := readBitwardenStatus(command, debugEnabled) if err != nil { - return fmt.Errorf("check bitwarden CLI status: %w", err) + return err } - trimmed := strings.TrimSpace(string(output)) - if trimmed == "" { - return fmt.Errorf("%w: bitwarden CLI returned an empty status", ErrBWUnavailable) - } - - var status bitwardenStatusOutput - if err := json.Unmarshal([]byte(trimmed), &status); err != nil { - return fmt.Errorf("decode bitwarden CLI status: %w", errors.Join(ErrBWUnavailable, err)) - } - - switch strings.ToLower(strings.TrimSpace(status.Status)) { + switch status { case "unauthenticated": return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn) case "locked": @@ -139,7 +142,15 @@ func EnsureBitwardenReady(options Options) error { unlockCommand, ) case "unlocked": + lookupEnv := options.LookupEnv + if lookupEnv == nil { + lookupEnv = os.LookupEnv + } + session, ok := lookupEnv(bitwardenSessionEnvName) + if !ok || strings.TrimSpace(session) == "" { + session, ok = os.LookupEnv(bitwardenSessionEnvName) + } if !ok || strings.TrimSpace(session) == "" { return fmt.Errorf( "%w: environment variable %q is missing; run `%s` then retry", @@ -153,11 +164,30 @@ func EnsureBitwardenReady(options Options) error { return fmt.Errorf( "%w: unsupported bitwarden status %q", ErrBWUnavailable, - strings.TrimSpace(status.Status), + status, ) } } +func readBitwardenStatus(command string, debug bool) (string, error) { + output, err := runBitwardenCommand(command, debug, nil, "status") + if err != nil { + return "", fmt.Errorf("check bitwarden CLI status: %w", err) + } + + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return "", fmt.Errorf("%w: bitwarden CLI returned an empty status", ErrBWUnavailable) + } + + var status bitwardenStatusOutput + if err := json.Unmarshal([]byte(trimmed), &status); err != nil { + return "", fmt.Errorf("decode bitwarden CLI status: %w", errors.Join(ErrBWUnavailable, err)) + } + + return strings.ToLower(strings.TrimSpace(status.Status)), nil +} + func bitwardenUnlockRemediation(command, shellHint string) string { unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command)) @@ -536,6 +566,19 @@ func runBitwardenCommand(command string, debug bool, stdin []byte, args ...strin return runBitwardenCLI(command, stdin, args...) } +func runBitwardenInteractiveCommand( + command string, + debug bool, + stdin io.Reader, + stdout, stderr io.Writer, + args ...string, +) ([]byte, error) { + if debug { + logBitwardenCommand(command, args...) + } + return runBitwardenInteractiveCLI(command, stdin, stdout, stderr, args...) +} + func isBitwardenDebugEnabled(explicit bool) bool { if explicit { return true @@ -615,6 +658,41 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, return stdout.Bytes(), nil } +func executeBitwardenCLIInteractive( + command string, + stdin io.Reader, + stdout, stderr io.Writer, + args ...string, +) ([]byte, error) { + stopLoader := startBitwardenLoader() + defer stopLoader() + + cmd := exec.Command(command, args...) + if stdin != nil { + cmd.Stdin = stdin + } + + var stdoutBuffer bytes.Buffer + if stdout == nil { + cmd.Stdout = &stdoutBuffer + } else { + cmd.Stdout = io.MultiWriter(stdout, &stdoutBuffer) + } + + var stderrBuffer bytes.Buffer + if stderr == nil { + cmd.Stderr = &stderrBuffer + } else { + cmd.Stderr = io.MultiWriter(stderr, &stderrBuffer) + } + + if err := cmd.Run(); err != nil { + return nil, normalizeBitwardenExecutionError(err, stderrBuffer.String(), stdoutBuffer.String()) + } + + return stdoutBuffer.Bytes(), nil +} + func startBitwardenLoader() func() { if !shouldShowBitwardenLoader() { return func() {} diff --git a/secretstore/bitwarden_session.go b/secretstore/bitwarden_session.go new file mode 100644 index 0000000..ff4d814 --- /dev/null +++ b/secretstore/bitwarden_session.go @@ -0,0 +1,197 @@ +package secretstore + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const bitwardenSessionFileName = "bw-session" + +var bitwardenUserConfigDir = os.UserConfigDir + +type BitwardenSessionOptions struct { + ServiceName string +} + +type BitwardenLoginOptions struct { + ServiceName string + BitwardenCommand string + BitwardenDebug bool + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +func SaveBitwardenSession(options BitwardenSessionOptions, session string) (string, error) { + trimmedSession := strings.TrimSpace(session) + if trimmedSession == "" { + return "", errors.New("bitwarden session must not be empty") + } + + path, err := resolveBitwardenSessionPath(options) + if err != nil { + return "", err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create bitwarden session dir %q: %w", dir, err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return "", fmt.Errorf("set bitwarden session dir permissions %q: %w", dir, err) + } + + tmpFile, err := os.CreateTemp(dir, "bw-session-*.tmp") + if err != nil { + return "", fmt.Errorf("create temp bitwarden session in %q: %w", dir, err) + } + + tmpPath := tmpFile.Name() + cleanup := true + defer func() { + _ = tmpFile.Close() + if cleanup { + _ = os.Remove(tmpPath) + } + }() + + if err := tmpFile.Chmod(0o600); err != nil { + return "", fmt.Errorf("set bitwarden session temp file permissions %q: %w", tmpPath, err) + } + if _, err := tmpFile.WriteString(trimmedSession + "\n"); err != nil { + return "", fmt.Errorf("write bitwarden session temp file %q: %w", tmpPath, err) + } + if err := tmpFile.Close(); err != nil { + return "", fmt.Errorf("close bitwarden session temp file %q: %w", tmpPath, err) + } + if err := os.Rename(tmpPath, path); err != nil { + return "", fmt.Errorf("replace bitwarden session file %q: %w", path, err) + } + if err := os.Chmod(path, 0o600); err != nil { + return "", fmt.Errorf("set bitwarden session file permissions %q: %w", path, err) + } + + cleanup = false + return path, nil +} + +func LoadBitwardenSession(options BitwardenSessionOptions) (string, error) { + path, err := resolveBitwardenSessionPath(options) + if err != nil { + return "", err + } + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", ErrNotFound + } + return "", fmt.Errorf("read bitwarden session file %q: %w", path, err) + } + + session := strings.TrimSpace(string(data)) + if session == "" { + return "", ErrNotFound + } + + return session, nil +} + +func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) { + if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" { + return false, nil + } + + session, err := LoadBitwardenSession(options) + if err != nil { + if errors.Is(err, ErrNotFound) { + return false, nil + } + return false, err + } + + if err := os.Setenv(bitwardenSessionEnvName, session); err != nil { + return false, fmt.Errorf("set %s from persisted bitwarden session: %w", bitwardenSessionEnvName, err) + } + + return true, nil +} + +func LoginBitwarden(options BitwardenLoginOptions) (string, error) { + serviceName := strings.TrimSpace(options.ServiceName) + if serviceName == "" { + return "", errors.New("service name must not be empty") + } + + command := strings.TrimSpace(options.BitwardenCommand) + if command == "" { + command = defaultBitwardenCommand + } + debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) + + stdin := options.Stdin + if stdin == nil { + stdin = os.Stdin + } + stdout := options.Stdout + if stdout == nil { + stdout = os.Stdout + } + stderr := options.Stderr + if stderr == nil { + stderr = os.Stderr + } + + status, err := readBitwardenStatus(command, debugEnabled) + if err != nil { + return "", err + } + + switch status { + case "unauthenticated": + if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil { + return "", fmt.Errorf("login to bitwarden CLI: %w", err) + } + case "locked", "unlocked": + default: + return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status) + } + + unlockOutput, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, nil, stderr, "unlock", "--raw") + if err != nil { + return "", fmt.Errorf("unlock bitwarden vault: %w", err) + } + + session := strings.TrimSpace(string(unlockOutput)) + if session == "" { + return "", errors.New("bitwarden CLI returned an empty session after unlock") + } + + if err := os.Setenv(bitwardenSessionEnvName, session); err != nil { + return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err) + } + + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil { + return "", fmt.Errorf("persist bitwarden session: %w", err) + } + + return session, nil +} + +func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) { + serviceName := strings.TrimSpace(options.ServiceName) + if serviceName == "" { + return "", errors.New("service name must not be empty") + } + + userConfigDir, err := bitwardenUserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve user config dir for bitwarden session: %w", err) + } + + return filepath.Join(userConfigDir, serviceName, bitwardenSessionFileName), nil +} diff --git a/secretstore/bitwarden_session_test.go b/secretstore/bitwarden_session_test.go new file mode 100644 index 0000000..3b8e3e4 --- /dev/null +++ b/secretstore/bitwarden_session_test.go @@ -0,0 +1,231 @@ +package secretstore + +import ( + "errors" + "fmt" + "io" + "os" + "slices" + "testing" +) + +func TestLoginBitwardenRunsInteractiveFlowAndPersistsSession(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"unauthenticated"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + var calls [][]string + withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { + calls = append(calls, slices.Clone(args)) + switch { + case len(args) == 1 && args[0] == "login": + return nil, nil + case len(args) == 2 && args[0] == "unlock" && args[1] == "--raw": + return []byte("persisted-session\n"), nil + default: + return nil, fmt.Errorf("unexpected interactive args: %v", args) + } + }) + + session, err := LoginBitwarden(BitwardenLoginOptions{ + ServiceName: "email-mcp", + BitwardenCommand: "bw", + }) + if err != nil { + t.Fatalf("LoginBitwarden returned error: %v", err) + } + if session != "persisted-session" { + t.Fatalf("session = %q, want persisted-session", session) + } + if got := os.Getenv("BW_SESSION"); got != "persisted-session" { + t.Fatalf("BW_SESSION = %q, want persisted-session", got) + } + + if len(calls) != 2 { + t.Fatalf("interactive call count = %d, want 2", len(calls)) + } + if !slices.Equal(calls[0], []string{"login"}) { + t.Fatalf("interactive call #1 args = %v, want [login]", calls[0]) + } + if !slices.Equal(calls[1], []string{"unlock", "--raw"}) { + t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1]) + } + + persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if err != nil { + t.Fatalf("LoadBitwardenSession returned error: %v", err) + } + if persisted != "persisted-session" { + t.Fatalf("persisted session = %q, want persisted-session", persisted) + } +} + +func TestLoginBitwardenSkipsInteractiveLoginWhenAlreadyAuthenticated(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"locked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + var calls [][]string + withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { + calls = append(calls, slices.Clone(args)) + if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" { + return []byte("session-locked\n"), nil + } + return nil, fmt.Errorf("unexpected interactive args: %v", args) + }) + + session, err := LoginBitwarden(BitwardenLoginOptions{ + ServiceName: "email-mcp", + BitwardenCommand: "bw", + }) + if err != nil { + t.Fatalf("LoginBitwarden returned error: %v", err) + } + if session != "session-locked" { + t.Fatalf("session = %q, want session-locked", session) + } + + if len(calls) != 1 { + t.Fatalf("interactive call count = %d, want 1", len(calls)) + } + if !slices.Equal(calls[0], []string{"unlock", "--raw"}) { + t.Fatalf("interactive call args = %v, want [unlock --raw]", calls[0]) + } +} + +func TestBitwardenSessionEnvLoadsFromPersistedSession(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + path, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session") + if err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat persisted session file: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("session file mode = %o, want 600", got) + } + + loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if err != nil { + t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err) + } + if !loaded { + t.Fatal("expected session to be loaded from persisted file") + } + if got := os.Getenv("BW_SESSION"); got != "persisted-session" { + t.Fatalf("BW_SESSION = %q, want persisted-session", got) + } +} + +func TestBitwardenSessionEnvDoesNotOverrideExistingValue(t *testing.T) { + t.Setenv("BW_SESSION", "from-env") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session"); err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if err != nil { + t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err) + } + if loaded { + t.Fatal("expected existing BW_SESSION to be kept") + } + if got := os.Getenv("BW_SESSION"); got != "from-env" { + t.Fatalf("BW_SESSION = %q, want from-env", got) + } +} + +func TestOpenBitwardenCLILoadsPersistedSessionWhenEnvironmentIsMissing(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-open-session"); err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + if _, ok := store.(*bitwardenStore); !ok { + t.Fatalf("store type = %T, want *bitwardenStore", store) + } + if got := os.Getenv("BW_SESSION"); got != "persisted-open-session" { + t.Fatalf("BW_SESSION = %q, want persisted-open-session", got) + } +} + +func TestLoadBitwardenSessionReturnsNotFoundWhenFileMissing(t *testing.T) { + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + _, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("error = %v, want ErrNotFound", err) + } +} + +func withBitwardenInteractiveRunner( + t *testing.T, + runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error), +) { + t.Helper() + + previous := runBitwardenInteractiveCLI + runBitwardenInteractiveCLI = runner + t.Cleanup(func() { + runBitwardenInteractiveCLI = previous + }) +} + +func withBitwardenUserConfigDir(t *testing.T, resolver func() (string, error)) { + t.Helper() + + previous := bitwardenUserConfigDir + bitwardenUserConfigDir = resolver + t.Cleanup(func() { + bitwardenUserConfigDir = previous + }) +} -- 2.45.2 From 017005b0b1033e518ea7b243d0ff982039712de7 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 14:24:43 +0200 Subject: [PATCH 39/79] fix(secretstore): disable loader during interactive bitwarden prompts --- secretstore/bitwarden.go | 6 ++---- secretstore/bitwarden_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 25765ca..362c019 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -42,6 +42,7 @@ type bitwardenInteractiveRunner func( var runBitwardenCLI bitwardenRunner = executeBitwardenCLI var runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive +var startBitwardenLoaderFunc = startBitwardenLoader var bitwardenLoaderActive atomic.Bool var bitwardenDebugOutput io.Writer = os.Stderr @@ -638,7 +639,7 @@ func sanitizeBitwardenDebugArgs(args []string) []string { } func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { - stopLoader := startBitwardenLoader() + stopLoader := startBitwardenLoaderFunc() defer stopLoader() cmd := exec.Command(command, args...) @@ -664,9 +665,6 @@ func executeBitwardenCLIInteractive( stdout, stderr io.Writer, args ...string, ) ([]byte, error) { - stopLoader := startBitwardenLoader() - defer stopLoader() - cmd := exec.Command(command, args...) if stdin != nil { cmd.Stdin = stdin diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 5da8fe2..4b64c57 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "os" "os/exec" "path/filepath" "regexp" @@ -484,6 +485,28 @@ func TestBitwardenLoaderFrameMovesAndWrapsTheWave(t *testing.T) { } } +func TestExecuteBitwardenCLIInteractiveSkipsLoader(t *testing.T) { + loaderStartCount := 0 + withBitwardenLoaderStarter(t, func() func() { + loaderStartCount++ + return func() {} + }) + + _, err := executeBitwardenCLIInteractive( + os.Args[0], + nil, + io.Discard, + io.Discard, + "-test.run=^$", + ) + if err != nil { + t.Fatalf("executeBitwardenCLIInteractive returned error: %v", err) + } + if loaderStartCount != 0 { + t.Fatalf("loader start count = %d, want 0 for interactive command", loaderStartCount) + } +} + var ansiControlSequencePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) func stripANSIControlSequences(value string) string { @@ -552,6 +575,16 @@ func withBitwardenDebugOutput(t *testing.T, writer io.Writer) { }) } +func withBitwardenLoaderStarter(t *testing.T, starter func() func()) { + t.Helper() + + previous := startBitwardenLoaderFunc + startBitwardenLoaderFunc = starter + t.Cleanup(func() { + startBitwardenLoaderFunc = previous + }) +} + type fakeBitwardenCLI struct { command string itemsByID map[string]fakeBitwardenItem -- 2.45.2 From ef22b1aa8a351d61835581a14a9ec896fe1ad93a Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 17:52:50 +0200 Subject: [PATCH 40/79] fix: prompt login in red when bitwarden session is missing --- scaffold/scaffold.go | 40 ++++++++++++++++++++++++++++++++++++--- scaffold/scaffold_test.go | 2 ++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 9723a58..ca72024 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -1596,6 +1596,11 @@ type Runtime struct { SecretName string } +const ( + ansiRedColor = "\033[31m" + ansiResetColor = "\033[0m" +) + func Run(ctx context.Context, args []string, version string) error { runtime, err := NewRuntime(version) if err != nil { @@ -1917,9 +1922,7 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error func (r Runtime) openSecretStore() (secretstore.Store, error) { policy := r.activeBackendPolicy() if policy == secretstore.BackendBitwardenCLI { - if _, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ - ServiceName: r.BinaryName, - }); err != nil { + if err := r.ensureBitwardenSession(); err != nil { return nil, err } } @@ -1936,6 +1939,37 @@ func (r Runtime) openSecretStore() (secretstore.Store, error) { }) } +func (r Runtime) ensureBitwardenSession() error { + if hasBitwardenSessionInEnv() { + return nil + } + + loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ + ServiceName: r.BinaryName, + }) + if err != nil { + return err + } + + if loaded || hasBitwardenSessionInEnv() { + return nil + } + + return errors.New(colorizeRed(fmt.Sprintf( + "Session Bitwarden introuvable. Lance %s login puis relance la commande.", + r.BinaryName, + ))) +} + +func hasBitwardenSessionInEnv() bool { + session, ok := os.LookupEnv("BW_SESSION") + return ok && strings.TrimSpace(session) != "" +} + +func colorizeRed(message string) string { + return ansiRedColor + strings.TrimSpace(message) + ansiResetColor +} + func (r Runtime) activeBackendPolicy() secretstore.BackendPolicy { policy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy)) if policy == "" { diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index d67353f..bd32a49 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -68,6 +68,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "config.NewStore[Profile]", "secretstore.Open(secretstore.Options", "secretstore.EnsureBitwardenSessionEnv", + "func (r Runtime) ensureBitwardenSession() error {", + "\\033[31m", "secretstore.LoginBitwarden", "update.Run", "manifest.LoadDefaultOrEmbedded", -- 2.45.2 From 20b5026f9d25d55c821c1f4de6e30aba41473829 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 11:46:47 +0200 Subject: [PATCH 41/79] feat: add manifest code generation --- README.md | 2 + cmd/mcp-framework/main.go | 68 ++++++++++- cmd/mcp-framework/main_test.go | 40 ++++++ docs/README.md | 1 + docs/generate.md | 59 +++++++++ generate/generate.go | 214 +++++++++++++++++++++++++++++++++ generate/generate_test.go | 213 ++++++++++++++++++++++++++++++++ 7 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 docs/generate.md create mode 100644 generate/generate.go create mode 100644 generate/generate_test.go diff --git a/README.md b/README.md index e9b682d..800642b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - Le framework fournit des briques réutilisables : config locale, secrets, résolution CLI, manifeste projet, et auto-update. - Il peut être utilisé de manière modulaire (package par package) ou avec un bootstrap CLI prêt à l'emploi. - Il inclut un générateur de squelette (`mcp-framework scaffold init`) pour démarrer un nouveau binaire MCP rapidement. +- Il peut générer la glue Go dérivée d'un manifeste racine (`mcp-framework generate`). - Toute la documentation détaillée est maintenant organisée dans `docs/` par grandes parties. ## Démarrage rapide @@ -43,6 +44,7 @@ go run ./cmd/my-mcp help - Packages : [docs/packages.md](docs/packages.md) - Bootstrap CLI : [docs/bootstrap-cli.md](docs/bootstrap-cli.md) - Manifeste `mcp.toml` : [docs/manifest.md](docs/manifest.md) +- Génération depuis `mcp.toml` : [docs/generate.md](docs/generate.md) - Scaffolding : [docs/scaffolding.md](docs/scaffolding.md) - Config JSON : [docs/config.md](docs/config.md) - Secrets : [docs/secrets.md](docs/secrets.md) diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go index a4a263f..bcc9345 100644 --- a/cmd/mcp-framework/main.go +++ b/cmd/mcp-framework/main.go @@ -6,8 +6,10 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" + generatepkg "gitea.lclr.dev/AI/mcp-framework/generate" scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold" ) @@ -34,6 +36,8 @@ func run(args []string, stdout, stderr io.Writer) error { } switch args[0] { + case "generate": + return runGenerate(args[1:], stdout, stderr) case "scaffold": return runScaffold(args[1:], stdout, stderr) default: @@ -41,6 +45,60 @@ func run(args []string, stdout, stderr io.Writer) error { } } +func runGenerate(args []string, stdout, stderr io.Writer) error { + if shouldShowHelp(args) { + printGenerateHelp(stdout) + return nil + } + + fs := flag.NewFlagSet("generate", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var manifestPath string + var packageDir string + var packageName string + var check bool + + fs.StringVar(&manifestPath, "manifest", "", "Chemin du mcp.toml à lire (défaut: ./mcp.toml)") + fs.StringVar(&packageDir, "package-dir", "mcpgen", "Répertoire du package Go généré") + fs.StringVar(&packageName, "package-name", "", "Nom du package Go généré (défaut: dérivé du dossier)") + fs.BoolVar(&check, "check", false, "Échoue si les fichiers générés ne sont pas à jour") + + if err := fs.Parse(args); err != nil { + _ = stderr + return fmt.Errorf("parse generate flags: %w", err) + } + + if fs.NArg() > 0 { + return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) + } + + result, err := generatepkg.Generate(generatepkg.Options{ + ManifestPath: manifestPath, + PackageDir: packageDir, + PackageName: packageName, + Check: check, + }) + if err != nil { + return err + } + + if check { + if _, err := fmt.Fprintln(stdout, "Generated files are up to date"); err != nil { + return err + } + return nil + } + + for _, file := range result.Files { + if _, err := fmt.Fprintf(stdout, "Generated %s\n", filepath.ToSlash(file)); err != nil { + return err + } + } + + return nil +} + func runScaffold(args []string, stdout, stderr io.Writer) error { if len(args) == 0 || isHelpArg(args[0]) { printScaffoldHelp(stdout) @@ -145,12 +203,20 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error { func printGlobalHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", + "Usage:\n %s [options]\n\nCommands:\n generate Génère la glue Go depuis mcp.toml\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", toolName, toolName, ) } +func printGenerateHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s generate [flags]\n\nFlags:\n --manifest Chemin du mcp.toml à lire\n --package-dir Répertoire du package Go généré (défaut: mcpgen)\n --package-name Nom du package Go généré (défaut: dérivé du dossier)\n --check Vérifie que les fichiers générés sont à jour\n", + toolName, + ) +} + func printScaffoldHelp(w io.Writer) { fmt.Fprintf( w, diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go index 0b17192..7468085 100644 --- a/cmd/mcp-framework/main_test.go +++ b/cmd/mcp-framework/main_test.go @@ -60,6 +60,46 @@ func TestRunScaffoldInitCreatesProject(t *testing.T) { } } +func TestRunGenerateCreatesManifestLoader(t *testing.T) { + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(`binary_name = "demo-mcp"`), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := run([]string{"generate", "--manifest", filepath.Join(projectDir, "mcp.toml")}, &stdout, &stderr) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(projectDir, "mcpgen", "manifest.go")); err != nil { + t.Fatalf("generated manifest.go missing: %v", err) + } + if !strings.Contains(stdout.String(), "Generated mcpgen/manifest.go") { + t.Fatalf("stdout should include generation summary: %q", stdout.String()) + } +} + +func TestRunGenerateCheckReturnsErrorWhenOutdated(t *testing.T) { + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(`binary_name = "demo-mcp"`), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := run([]string{"generate", "--manifest", filepath.Join(projectDir, "mcp.toml"), "--check"}, &stdout, &stderr) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "generated files are not up to date") { + t.Fatalf("error = %v", err) + } +} + func TestRunScaffoldInitRequiresTarget(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/docs/README.md b/docs/README.md index 83fedb6..4b0360e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Cette documentation est organisée par grandes parties pour séparer la vue d'en - [Packages](packages.md) - [Bootstrap CLI](bootstrap-cli.md) - [Manifeste `mcp.toml`](manifest.md) +- [Génération depuis `mcp.toml`](generate.md) - [Scaffolding](scaffolding.md) - [Config JSON](config.md) - [Secrets](secrets.md) diff --git a/docs/generate.md b/docs/generate.md new file mode 100644 index 0000000..6f9ea58 --- /dev/null +++ b/docs/generate.md @@ -0,0 +1,59 @@ +# Génération depuis `mcp.toml` + +La commande `mcp-framework generate` génère la glue Go dérivée du manifeste +racine d'un projet existant. Le premier usage couvert est le loader de +manifeste embarqué. + +## Usage + +Depuis la racine du projet consommateur : + +```bash +mcp-framework generate +``` + +La commande lit `./mcp.toml`, valide son contenu avec le package `manifest`, et +génère : + +```text +mcpgen/ + manifest.go +``` + +Le fichier généré expose : + +```go +func LoadManifest(startDir string) (manifest.File, string, error) +``` + +Cette fonction appelle `manifest.LoadDefaultOrEmbedded`. En développement, un +`mcp.toml` présent sur disque reste prioritaire. Pour un binaire copié seul, +elle utilise le contenu du manifeste embarqué au moment de la génération. + +## Flags + +- `--manifest` : chemin du `mcp.toml` à lire. Par défaut, `./mcp.toml`. +- `--package-dir` : répertoire du package généré. Par défaut, `mcpgen`. +- `--package-name` : nom du package Go généré. Par défaut, dérivé du dossier. +- `--check` : mode CI, échoue si les fichiers générés sont absents ou obsolètes. + +Exemple CI : + +```bash +mcp-framework generate --check +``` + +## Migration d'un wrapper manuel + +Pour remplacer un wrapper local du type `internal/manifest` : + +1. Déplacer le manifeste projet vers `mcp.toml` à la racine. +2. Lancer `mcp-framework generate`. +3. Remplacer les imports du wrapper local par le package généré, par exemple + `example.com/my-mcp/mcpgen`. +4. Remplacer les appels `manifest.Load(...)` du wrapper par + `mcpgen.LoadManifest(...)`. +5. Supprimer l'ancien wrapper manuel. + +Après génération, un simple `go build ./...` suffit. La compilation ne dépend +pas de la commande `mcp-framework`. diff --git a/generate/generate.go b/generate/generate.go new file mode 100644 index 0000000..7d4a497 --- /dev/null +++ b/generate/generate.go @@ -0,0 +1,214 @@ +package generate + +import ( + "bytes" + "errors" + "fmt" + "go/format" + "go/token" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +var ErrGeneratedFilesOutdated = errors.New("generated files are not up to date") + +type Options struct { + ProjectDir string + ManifestPath string + PackageDir string + PackageName string + Check bool +} + +type Result struct { + Root string + Files []string +} + +func Generate(options Options) (Result, error) { + normalized, err := normalizeOptions(options) + if err != nil { + return Result{}, err + } + + if _, err := manifest.Load(normalized.ManifestPath); err != nil { + return Result{}, err + } + + manifestContent, err := os.ReadFile(normalized.ManifestPath) + if err != nil { + return Result{}, fmt.Errorf("read manifest %s: %w", normalized.ManifestPath, err) + } + + content, err := renderManifestLoader(normalized.PackageName, string(manifestContent)) + if err != nil { + return Result{}, err + } + + files := []generatedFile{ + { + Path: filepath.Join(normalized.PackageDir, "manifest.go"), + Content: content, + Mode: 0o644, + }, + } + + written := make([]string, 0, len(files)) + for _, file := range files { + target := filepath.Join(normalized.ProjectDir, file.Path) + if normalized.Check { + current, err := os.ReadFile(target) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path) + } + return Result{}, fmt.Errorf("read generated file %q: %w", target, err) + } + if !bytes.Equal(current, []byte(file.Content)) { + return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path) + } + written = append(written, file.Path) + continue + } + + if err := writeGeneratedFile(target, file.Content, file.Mode); err != nil { + return Result{}, err + } + written = append(written, file.Path) + } + + sort.Strings(written) + return Result{ + Root: normalized.ProjectDir, + Files: written, + }, nil +} + +type normalizedOptions struct { + ProjectDir string + ManifestPath string + PackageDir string + PackageName string + Check bool +} + +type generatedFile struct { + Path string + Content string + Mode os.FileMode +} + +func normalizeOptions(options Options) (normalizedOptions, error) { + manifestPath := strings.TrimSpace(options.ManifestPath) + projectDir := strings.TrimSpace(options.ProjectDir) + + if manifestPath == "" { + baseDir := projectDir + if baseDir == "" { + wd, err := os.Getwd() + if err != nil { + return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err) + } + baseDir = wd + } + manifestPath = filepath.Join(baseDir, manifest.DefaultFile) + } else if !filepath.IsAbs(manifestPath) { + baseDir := projectDir + if baseDir == "" { + wd, err := os.Getwd() + if err != nil { + return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err) + } + baseDir = wd + } + manifestPath = filepath.Join(baseDir, manifestPath) + } + + resolvedManifest, err := filepath.Abs(manifestPath) + if err != nil { + return normalizedOptions{}, fmt.Errorf("resolve manifest path %q: %w", manifestPath, err) + } + + if projectDir == "" { + projectDir = filepath.Dir(resolvedManifest) + } + resolvedProjectDir, err := filepath.Abs(projectDir) + if err != nil { + return normalizedOptions{}, fmt.Errorf("resolve project dir %q: %w", projectDir, err) + } + + packageDir := filepath.Clean(strings.TrimSpace(options.PackageDir)) + if packageDir == "." || packageDir == "" { + packageDir = "mcpgen" + } + if filepath.IsAbs(packageDir) || packageDir == ".." || strings.HasPrefix(packageDir, ".."+string(filepath.Separator)) { + return normalizedOptions{}, fmt.Errorf("package dir %q must be relative to the project", options.PackageDir) + } + + packageName := strings.TrimSpace(options.PackageName) + if packageName == "" { + packageName = filepath.Base(packageDir) + } + if !token.IsIdentifier(packageName) { + return normalizedOptions{}, fmt.Errorf("package name %q is not a valid Go identifier", packageName) + } + + return normalizedOptions{ + ProjectDir: resolvedProjectDir, + ManifestPath: resolvedManifest, + PackageDir: packageDir, + PackageName: packageName, + Check: options.Check, + }, nil +} + +func renderManifestLoader(packageName, manifestContent string) (string, error) { + source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. + +package %s + +import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" + +const embeddedManifest = %s + +func LoadManifest(startDir string) (fwmanifest.File, string, error) { + return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest) +} +`, packageName, strconv.Quote(manifestContent)) + + formatted, err := format.Source([]byte(source)) + if err != nil { + return "", fmt.Errorf("format generated manifest loader: %w", err) + } + + return string(formatted), nil +} + +func writeGeneratedFile(path, content string, mode os.FileMode) error { + current, err := os.ReadFile(path) + if err == nil && bytes.Equal(current, []byte(content)) { + return nil + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read generated file %q: %w", path, err) + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create generated directory %q: %w", dir, err) + } + + if mode == 0 { + mode = 0o644 + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + return fmt.Errorf("write generated file %q: %w", path, err) + } + + return nil +} diff --git a/generate/generate_test.go b/generate/generate_test.go new file mode 100644 index 0000000..f75718e --- /dev/null +++ b/generate/generate_test.go @@ -0,0 +1,213 @@ +package generate + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestGenerateCreatesManifestLoader(t *testing.T) { + projectDir := newProject(t, ` +binary_name = "demo-mcp" +docs_url = "https://docs.example.com/demo" + +[bootstrap] +description = "Demo MCP" +`) + + result, err := Generate(Options{ProjectDir: projectDir}) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + if !slices.Equal(result.Files, []string{filepath.Join("mcpgen", "manifest.go")}) { + t.Fatalf("result files = %v", result.Files) + } + + generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go") + content, err := os.ReadFile(generatedPath) + if err != nil { + t.Fatalf("ReadFile generated manifest: %v", err) + } + + for _, snippet := range []string{ + "// Code generated by mcp-framework generate. DO NOT EDIT.", + "package mcpgen", + "import fwmanifest \"gitea.lclr.dev/AI/mcp-framework/manifest\"", + "const embeddedManifest = ", + "func LoadManifest(startDir string) (fwmanifest.File, string, error) {", + "return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)", + `binary_name = \"demo-mcp\"`, + } { + if !strings.Contains(string(content), snippet) { + t.Fatalf("generated manifest.go missing snippet %q:\n%s", snippet, content) + } + } +} + +func TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) { + projectDir := newProject(t, `binary_name = "demo-mcp"`) + + if _, err := Generate(Options{ProjectDir: projectDir}); err != nil { + t.Fatalf("first Generate returned error: %v", err) + } + generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go") + first, err := os.ReadFile(generatedPath) + if err != nil { + t.Fatalf("ReadFile first generated file: %v", err) + } + + if _, err := Generate(Options{ProjectDir: projectDir}); err != nil { + t.Fatalf("second Generate returned error: %v", err) + } + second, err := os.ReadFile(generatedPath) + if err != nil { + t.Fatalf("ReadFile second generated file: %v", err) + } + if string(second) != string(first) { + t.Fatalf("second generation changed content") + } + + if _, err := Generate(Options{ProjectDir: projectDir, Check: true}); err != nil { + t.Fatalf("check after generation returned error: %v", err) + } + + if err := os.WriteFile(generatedPath, append(second, []byte("// drift\n")...), 0o600); err != nil { + t.Fatalf("WriteFile drift: %v", err) + } + + _, err = Generate(Options{ProjectDir: projectDir, Check: true}) + if !errors.Is(err, ErrGeneratedFilesOutdated) { + t.Fatalf("check error = %v, want ErrGeneratedFilesOutdated", err) + } +} + +func TestGenerateSupportsManifestAndPackageFlags(t *testing.T) { + projectDir := t.TempDir() + manifestPath := filepath.Join(projectDir, "config", "custom.toml") + if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { + t.Fatalf("MkdirAll manifest dir: %v", err) + } + if err := os.WriteFile(manifestPath, []byte(`binary_name = "demo-mcp"`), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + + result, err := Generate(Options{ + ProjectDir: projectDir, + ManifestPath: manifestPath, + PackageDir: "internal/generated", + PackageName: "generated", + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + if !slices.Equal(result.Files, []string{filepath.Join("internal", "generated", "manifest.go")}) { + t.Fatalf("result files = %v", result.Files) + } + + content, err := os.ReadFile(filepath.Join(projectDir, "internal", "generated", "manifest.go")) + if err != nil { + t.Fatalf("ReadFile generated manifest: %v", err) + } + if !strings.Contains(string(content), "package generated") { + t.Fatalf("generated file should use package name: %s", content) + } +} + +func TestGenerateRejectsInvalidManifest(t *testing.T) { + projectDir := newProject(t, "[bootstrap\n") + + _, err := Generate(Options{ProjectDir: projectDir}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "parse manifest") { + t.Fatalf("error = %v", err) + } +} + +func TestGeneratedLoaderFallsBackToEmbeddedManifest(t *testing.T) { + projectDir := newProject(t, ` +binary_name = "embedded-demo" +docs_url = "https://docs.example.com/embedded" +`) + writeModule(t, projectDir) + + if _, err := Generate(Options{ProjectDir: projectDir}); err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + if err := os.Remove(filepath.Join(projectDir, "mcp.toml")); err != nil { + t.Fatalf("Remove runtime manifest: %v", err) + } + + cmd := exec.Command("go", "test", "./...") + cmd.Dir = projectDir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go test generated project: %v\n%s", err, output) + } +} + +func newProject(t *testing.T, manifest string) string { + t.Helper() + + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(manifest), 0o600); err != nil { + t.Fatalf("WriteFile manifest: %v", err) + } + return projectDir +} + +func writeModule(t *testing.T, projectDir string) { + t.Helper() + + repoRoot, err := filepath.Abs("..") + if err != nil { + t.Fatalf("Abs repo root: %v", err) + } + + goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n" + if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil { + t.Fatalf("WriteFile go.mod: %v", err) + } + + goSum, err := os.ReadFile(filepath.Join(repoRoot, "go.sum")) + if err != nil { + t.Fatalf("ReadFile go.sum: %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "go.sum"), goSum, 0o600); err != nil { + t.Fatalf("WriteFile go.sum: %v", err) + } + + testFile := `package main + +import ( + "testing" + + "example.com/generated-demo/mcpgen" + fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +func TestGeneratedLoaderUsesEmbeddedManifest(t *testing.T) { + file, source, err := mcpgen.LoadManifest(".") + if err != nil { + t.Fatalf("LoadManifest returned error: %v", err) + } + if source != fwmanifest.EmbeddedSource { + t.Fatalf("source = %q, want %q", source, fwmanifest.EmbeddedSource) + } + if file.BinaryName != "embedded-demo" { + t.Fatalf("binary name = %q", file.BinaryName) + } +} +` + if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil { + t.Fatalf("WriteFile main_test.go: %v", err) + } +} -- 2.45.2 From a79f73825f149926d0197764cccba2bebad2c2d4 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 11:57:44 +0200 Subject: [PATCH 42/79] feat: generate manifest helper glue --- docs/generate.md | 57 ++++++++- generate/generate.go | 237 +++++++++++++++++++++++++++++++++++++- generate/generate_test.go | 157 ++++++++++++++++++++++++- 3 files changed, 439 insertions(+), 12 deletions(-) diff --git a/docs/generate.md b/docs/generate.md index 6f9ea58..ebf0d03 100644 --- a/docs/generate.md +++ b/docs/generate.md @@ -1,8 +1,7 @@ # Génération depuis `mcp.toml` La commande `mcp-framework generate` génère la glue Go dérivée du manifeste -racine d'un projet existant. Le premier usage couvert est le loader de -manifeste embarqué. +racine d'un projet existant. ## Usage @@ -18,9 +17,12 @@ génère : ```text mcpgen/ manifest.go + metadata.go + update.go + secretstore.go ``` -Le fichier généré expose : +Le package généré expose le loader de manifeste : ```go func LoadManifest(startDir string) (manifest.File, string, error) @@ -30,6 +32,48 @@ Cette fonction appelle `manifest.LoadDefaultOrEmbedded`. En développement, un `mcp.toml` présent sur disque reste prioritaire. Pour un binaire copié seul, elle utilise le contenu du manifeste embarqué au moment de la génération. +Il expose aussi des helpers dérivés du manifeste : + +```go +const BinaryName = "my-mcp" +const DefaultDescription = "..." +const DocsURL = "..." + +func BootstrapInfo(startDir string) (manifest.BootstrapMetadata, string, error) +func ScaffoldInfo(startDir string) (manifest.ScaffoldMetadata, string, error) +``` + +Pour l'auto-update : + +```go +func UpdateOptions(version string, stdout io.Writer) (update.Options, error) +func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (update.Options, error) +func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error +func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error +``` + +`RunUpdate` parse les flags de la commande `update`, refuse les arguments +positionnels, charge le manifeste via `LoadManifest`, puis appelle +`update.Run`. + +Pour les secrets : + +```go +type SecretStoreOptions struct { + ServiceName string + LookupEnv func(string) (string, bool) +} + +func OpenSecretStore(options SecretStoreOptions) (secretstore.Store, error) +func DescribeSecretRuntime(options SecretStoreOptions) (secretstore.RuntimeDescription, error) +func PreflightSecretStore(options SecretStoreOptions) (secretstore.PreflightReport, error) +``` + +`SecretStoreOptions` contient aussi les options techniques du package +`secretstore` (`KWalletAppID`, `KWalletFolder`, `BitwardenCommand`, +`BitwardenDebug`, `Shell`, `ExecutableResolver`). Si `ServiceName` est vide, +le nom du binaire déclaré dans le manifeste est utilisé. + ## Flags - `--manifest` : chemin du `mcp.toml` à lire. Par défaut, `./mcp.toml`. @@ -53,7 +97,12 @@ Pour remplacer un wrapper local du type `internal/manifest` : `example.com/my-mcp/mcpgen`. 4. Remplacer les appels `manifest.Load(...)` du wrapper par `mcpgen.LoadManifest(...)`. -5. Supprimer l'ancien wrapper manuel. +5. Remplacer les reconstructions locales d'options update par + `mcpgen.UpdateOptions(...)` ou `mcpgen.RunUpdate(...)`. +6. Remplacer les wrappers secret store qui ne font que brancher le loader par + `mcpgen.OpenSecretStore`, `mcpgen.DescribeSecretRuntime` et + `mcpgen.PreflightSecretStore`. +7. Supprimer l'ancien wrapper manuel. Après génération, un simple `go build ./...` suffit. La compilation ne dépend pas de la commande `mcp-framework`. diff --git a/generate/generate.go b/generate/generate.go index 7d4a497..711cedf 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -36,7 +36,8 @@ func Generate(options Options) (Result, error) { return Result{}, err } - if _, err := manifest.Load(normalized.ManifestPath); err != nil { + manifestFile, err := manifest.Load(normalized.ManifestPath) + if err != nil { return Result{}, err } @@ -45,7 +46,19 @@ func Generate(options Options) (Result, error) { return Result{}, fmt.Errorf("read manifest %s: %w", normalized.ManifestPath, err) } - content, err := renderManifestLoader(normalized.PackageName, string(manifestContent)) + manifestLoader, err := renderManifestLoader(normalized.PackageName, string(manifestContent)) + if err != nil { + return Result{}, err + } + metadata, err := renderMetadata(normalized.PackageName, manifestFile) + if err != nil { + return Result{}, err + } + update, err := renderUpdate(normalized.PackageName) + if err != nil { + return Result{}, err + } + secretstore, err := renderSecretStore(normalized.PackageName) if err != nil { return Result{}, err } @@ -53,7 +66,22 @@ func Generate(options Options) (Result, error) { files := []generatedFile{ { Path: filepath.Join(normalized.PackageDir, "manifest.go"), - Content: content, + Content: manifestLoader, + Mode: 0o644, + }, + { + Path: filepath.Join(normalized.PackageDir, "metadata.go"), + Content: metadata, + Mode: 0o644, + }, + { + Path: filepath.Join(normalized.PackageDir, "update.go"), + Content: update, + Mode: 0o644, + }, + { + Path: filepath.Join(normalized.PackageDir, "secretstore.go"), + Content: secretstore, Mode: 0o644, }, } @@ -189,6 +217,209 @@ func LoadManifest(startDir string) (fwmanifest.File, string, error) { return string(formatted), nil } +func renderMetadata(packageName string, manifestFile manifest.File) (string, error) { + bootstrapInfo := manifestFile.BootstrapInfo() + + source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. + +package %s + +import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" + +const BinaryName = %s +const DefaultDescription = %s +const DocsURL = %s + +func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) { + manifestFile, source, err := LoadManifest(startDir) + if err != nil { + return fwmanifest.BootstrapMetadata{}, "", err + } + + return manifestFile.BootstrapInfo(), source, nil +} + +func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) { + manifestFile, source, err := LoadManifest(startDir) + if err != nil { + return fwmanifest.ScaffoldMetadata{}, "", err + } + + return manifestFile.ScaffoldInfo(), source, nil +} +`, packageName, strconv.Quote(manifestFile.BinaryName), strconv.Quote(bootstrapInfo.Description), strconv.Quote(manifestFile.DocsURL)) + + return formatGenerated("metadata", source) +} + +func renderUpdate(packageName string) (string, error) { + source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. + +package %s + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + + fwupdate "gitea.lclr.dev/AI/mcp-framework/update" +) + +func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) { + return UpdateOptionsFrom(".", version, stdout) +} + +func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) { + manifestFile, _, err := LoadManifest(startDir) + if err != nil { + return fwupdate.Options{}, err + } + + binaryName := strings.TrimSpace(manifestFile.BinaryName) + if binaryName == "" { + binaryName = BinaryName + } + + return fwupdate.Options{ + CurrentVersion: version, + Stdout: stdout, + BinaryName: binaryName, + ReleaseSource: manifestFile.Update.ReleaseSource(), + }, nil +} + +func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error { + return RunUpdateFrom(ctx, args, ".", version, stdout) +} + +func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error { + fs := flag.NewFlagSet("update", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("update does not accept positional arguments: %%s", strings.Join(fs.Args(), ", ")) + } + + options, err := UpdateOptionsFrom(startDir, version, stdout) + if err != nil { + return err + } + + return fwupdate.Run(ctx, options) +} +`, packageName) + + return formatGenerated("update", source) +} + +func renderSecretStore(packageName string) (string, error) { + source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. + +package %s + +import ( + "os" + "path/filepath" + "strings" + + fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type SecretStoreOptions struct { + ServiceName string + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + BitwardenDebug bool + Shell string + ExecutableResolver fwsecretstore.ExecutableResolver +} + +func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) { + return fwsecretstore.OpenFromManifest(secretStoreOpenOptions(options)) +} + +func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) { + return fwsecretstore.DescribeRuntime(secretStoreDescribeOptions(options)) +} + +func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) { + return fwsecretstore.PreflightFromManifest(secretStoreDescribeOptions(options)) +} + +func secretStoreOpenOptions(options SecretStoreOptions) fwsecretstore.OpenFromManifestOptions { + return fwsecretstore.OpenFromManifestOptions{ + ServiceName: secretStoreServiceName(options), + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + BitwardenCommand: options.BitwardenCommand, + BitwardenDebug: options.BitwardenDebug, + Shell: options.Shell, + ManifestLoader: LoadManifest, + ExecutableResolver: options.ExecutableResolver, + } +} + +func secretStoreDescribeOptions(options SecretStoreOptions) fwsecretstore.DescribeRuntimeOptions { + return fwsecretstore.DescribeRuntimeOptions{ + ServiceName: secretStoreServiceName(options), + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + BitwardenCommand: options.BitwardenCommand, + BitwardenDebug: options.BitwardenDebug, + Shell: options.Shell, + ManifestLoader: LoadManifest, + ExecutableResolver: options.ExecutableResolver, + } +} + +func secretStoreServiceName(options SecretStoreOptions) string { + serviceName := strings.TrimSpace(options.ServiceName) + if serviceName != "" { + return serviceName + } + + startDir := "." + executableResolver := options.ExecutableResolver + if executableResolver == nil { + executableResolver = os.Executable + } + if executablePath, err := executableResolver(); err == nil { + if dir := strings.TrimSpace(filepath.Dir(strings.TrimSpace(executablePath))); dir != "" { + startDir = dir + } + } + + if manifestFile, _, err := LoadManifest(startDir); err == nil { + if binaryName := strings.TrimSpace(manifestFile.BinaryName); binaryName != "" { + return binaryName + } + } + + return BinaryName +} +`, packageName) + + return formatGenerated("secretstore", source) +} + +func formatGenerated(name, source string) (string, error) { + formatted, err := format.Source([]byte(source)) + if err != nil { + return "", fmt.Errorf("format generated %s: %w", name, err) + } + + return string(formatted), nil +} + func writeGeneratedFile(path, content string, mode os.FileMode) error { current, err := os.ReadFile(path) if err == nil && bytes.Equal(current, []byte(content)) { diff --git a/generate/generate_test.go b/generate/generate_test.go index f75718e..0375689 100644 --- a/generate/generate_test.go +++ b/generate/generate_test.go @@ -24,7 +24,7 @@ description = "Demo MCP" t.Fatalf("Generate returned error: %v", err) } - if !slices.Equal(result.Files, []string{filepath.Join("mcpgen", "manifest.go")}) { + if !slices.Equal(result.Files, defaultGeneratedFiles("mcpgen")) { t.Fatalf("result files = %v", result.Files) } @@ -49,6 +49,87 @@ description = "Demo MCP" } } +func TestGenerateCreatesP1Helpers(t *testing.T) { + projectDir := newProject(t, ` +binary_name = "demo-mcp" +docs_url = "https://docs.example.com/demo" + +[update] +driver = "gitea" +repository = "org/demo-mcp" +base_url = "https://gitea.example.com" +asset_name_template = "{binary}-{os}-{arch}{ext}" + +[secret_store] +backend_policy = "env-only" + +[bootstrap] +description = "Demo MCP" +`) + + result, err := Generate(Options{ProjectDir: projectDir}) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + wantFiles := []string{ + filepath.Join("mcpgen", "manifest.go"), + filepath.Join("mcpgen", "metadata.go"), + filepath.Join("mcpgen", "secretstore.go"), + filepath.Join("mcpgen", "update.go"), + } + if !slices.Equal(result.Files, wantFiles) { + t.Fatalf("result files = %v, want %v", result.Files, wantFiles) + } + + metadata, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "metadata.go")) + if err != nil { + t.Fatalf("ReadFile metadata.go: %v", err) + } + for _, snippet := range []string{ + `const BinaryName = "demo-mcp"`, + `const DefaultDescription = "Demo MCP"`, + `const DocsURL = "https://docs.example.com/demo"`, + "func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) {", + "func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) {", + } { + if !strings.Contains(string(metadata), snippet) { + t.Fatalf("metadata.go missing snippet %q:\n%s", snippet, metadata) + } + } + + update, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "update.go")) + if err != nil { + t.Fatalf("ReadFile update.go: %v", err) + } + for _, snippet := range []string{ + "func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) {", + "func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) {", + "func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error {", + "ReleaseSource:", + } { + if !strings.Contains(string(update), snippet) { + t.Fatalf("update.go missing snippet %q:\n%s", snippet, update) + } + } + + secretstore, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go")) + if err != nil { + t.Fatalf("ReadFile secretstore.go: %v", err) + } + for _, snippet := range []string{ + "type SecretStoreOptions struct {", + "func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) {", + "func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) {", + "func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) {", + "ManifestLoader:", + } { + if !strings.Contains(string(secretstore), snippet) { + t.Fatalf("secretstore.go missing snippet %q:\n%s", snippet, secretstore) + } + } +} + func TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) { projectDir := newProject(t, `binary_name = "demo-mcp"`) @@ -106,7 +187,7 @@ func TestGenerateSupportsManifestAndPackageFlags(t *testing.T) { t.Fatalf("Generate returned error: %v", err) } - if !slices.Equal(result.Files, []string{filepath.Join("internal", "generated", "manifest.go")}) { + if !slices.Equal(result.Files, defaultGeneratedFiles(filepath.Join("internal", "generated"))) { t.Fatalf("result files = %v", result.Files) } @@ -135,6 +216,17 @@ func TestGeneratedLoaderFallsBackToEmbeddedManifest(t *testing.T) { projectDir := newProject(t, ` binary_name = "embedded-demo" docs_url = "https://docs.example.com/embedded" + +[update] +driver = "gitea" +repository = "org/embedded-demo" +base_url = "https://gitea.example.com" + +[secret_store] +backend_policy = "env-only" + +[bootstrap] +description = "Embedded Demo" `) writeModule(t, projectDir) @@ -146,7 +238,7 @@ docs_url = "https://docs.example.com/embedded" t.Fatalf("Remove runtime manifest: %v", err) } - cmd := exec.Command("go", "test", "./...") + cmd := exec.Command("go", "test", "-mod=mod", "./...") cmd.Dir = projectDir output, err := cmd.CombinedOutput() if err != nil { @@ -164,6 +256,15 @@ func newProject(t *testing.T, manifest string) string { return projectDir } +func defaultGeneratedFiles(packageDir string) []string { + return []string{ + 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() @@ -172,7 +273,7 @@ func writeModule(t *testing.T, projectDir string) { t.Fatalf("Abs repo root: %v", err) } - goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n" + goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n" if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil { t.Fatalf("WriteFile go.mod: %v", err) } @@ -188,13 +289,15 @@ func writeModule(t *testing.T, projectDir string) { testFile := `package main import ( + "io" "testing" + fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" "example.com/generated-demo/mcpgen" fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" ) -func TestGeneratedLoaderUsesEmbeddedManifest(t *testing.T) { +func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) { file, source, err := mcpgen.LoadManifest(".") if err != nil { t.Fatalf("LoadManifest returned error: %v", err) @@ -205,6 +308,50 @@ func TestGeneratedLoaderUsesEmbeddedManifest(t *testing.T) { if file.BinaryName != "embedded-demo" { t.Fatalf("binary name = %q", file.BinaryName) } + + info, source, err := mcpgen.BootstrapInfo(".") + if err != nil { + t.Fatalf("BootstrapInfo returned error: %v", err) + } + if source != fwmanifest.EmbeddedSource { + t.Fatalf("bootstrap source = %q, want %q", source, fwmanifest.EmbeddedSource) + } + if info.Description != "Embedded Demo" { + t.Fatalf("description = %q", info.Description) + } + + updateOptions, err := mcpgen.UpdateOptions("1.2.3", io.Discard) + if err != nil { + t.Fatalf("UpdateOptions returned error: %v", err) + } + if updateOptions.CurrentVersion != "1.2.3" { + t.Fatalf("current version = %q", updateOptions.CurrentVersion) + } + if updateOptions.BinaryName != "embedded-demo" { + t.Fatalf("update binary name = %q", updateOptions.BinaryName) + } + if updateOptions.ReleaseSource.Repository != "org/embedded-demo" { + t.Fatalf("release repository = %q", updateOptions.ReleaseSource.Repository) + } + + store, err := mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{ + LookupEnv: func(name string) (string, bool) { + return "secret-from-env", true + }, + }) + if err != nil { + t.Fatalf("OpenSecretStore returned error: %v", err) + } + value, err := store.GetSecret("profile/default/api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "secret-from-env" { + t.Fatalf("secret value = %q", value) + } + if fwsecretstore.EffectiveBackendPolicy(store) != fwsecretstore.BackendEnvOnly { + t.Fatalf("effective backend = %q", fwsecretstore.EffectiveBackendPolicy(store)) + } } ` if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil { -- 2.45.2 From 17b1b9968652980abbdd3ce6d852a7cdb179bcfa Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 12:02:23 +0200 Subject: [PATCH 43/79] feat: generate config field helpers --- docs/generate.md | 19 +++++ docs/manifest.md | 35 ++++++++ generate/generate.go | 169 ++++++++++++++++++++++++++++++++++++++ generate/generate_test.go | 112 +++++++++++++++++++++++++ manifest/manifest.go | 37 +++++++++ manifest/manifest_test.go | 73 ++++++++++++++++ 6 files changed, 445 insertions(+) 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] -- 2.45.2 From afe4c681a19c6755965424956f8118ff735c22db Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 12:07:34 +0200 Subject: [PATCH 44/79] docs: refresh usage documentation --- README.md | 94 ++++++++++++++++++++++++++++------------- docs/README.md | 6 ++- docs/auto-update.md | 2 +- docs/generate.md | 53 ++++++++++++++++------- docs/getting-started.md | 24 +++++++---- docs/limitations.md | 2 +- docs/minimal-example.md | 51 ++++++++++++++++------ docs/packages.md | 1 + docs/scaffolding.md | 2 +- docs/secrets.md | 8 ++-- 10 files changed, 169 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 800642b..e874d95 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,90 @@ # mcp-framework -`mcp-framework` est une bibliothèque Go pour construire des binaires MCP robustes, sans imposer un runtime lourd. +`mcp-framework` est une bibliothèque Go et un petit CLI pour construire des +binaires MCP avec une base commune : CLI, configuration locale, secrets, +manifeste `mcp.toml`, diagnostic et auto-update. -## Le principal à savoir +## Installation -- Le framework fournit des briques réutilisables : config locale, secrets, résolution CLI, manifeste projet, et auto-update. -- Il peut être utilisé de manière modulaire (package par package) ou avec un bootstrap CLI prêt à l'emploi. -- Il inclut un générateur de squelette (`mcp-framework scaffold init`) pour démarrer un nouveau binaire MCP rapidement. -- Il peut générer la glue Go dérivée d'un manifeste racine (`mcp-framework generate`). -- Toute la documentation détaillée est maintenant organisée dans `docs/` par grandes parties. - -## Démarrage rapide - -Installer le framework dans un projet Go existant : +Dans un projet Go : ```bash go get gitea.lclr.dev/AI/mcp-framework ``` -Initialiser un nouveau projet MCP depuis un dossier vide : +Pour utiliser le CLI : ```bash go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest +``` + +## Créer un projet MCP + +```bash mcp-framework scaffold init \ --target ./my-mcp \ --module example.com/my-mcp \ --binary my-mcp \ --profiles dev,prod -``` -Puis dans le projet généré : - -```bash cd my-mcp go mod tidy go run ./cmd/my-mcp help ``` +Le scaffold crée une arborescence prête à adapter : + +```text +cmd//main.go +internal/app/app.go +mcp.toml +install.sh +README.md +``` + +## Générer la glue depuis `mcp.toml` + +Dans un projet qui possède un `mcp.toml` à la racine : + +```bash +mcp-framework generate +``` + +La commande génère un package `mcpgen/` avec un loader de manifeste embarqué, +des helpers de métadonnées, update, secret store, et des helpers de config si +`[[config.fields]]` est déclaré. + +En CI : + +```bash +mcp-framework generate --check +``` + +## Utiliser les packages + +Les packages peuvent être utilisés séparément : + +- `bootstrap` : CLI commune (`setup`, `login`, `mcp`, `config`, `update`, `version`). +- `cli` : résolution de profil, setup interactif, résolution `flag/env/config/secret`, doctor. +- `config` : stockage JSON versionné dans le répertoire de config utilisateur. +- `manifest` : lecture de `mcp.toml` et fallback embarqué. +- `secretstore` : keyring natif, environnement ou Bitwarden CLI. +- `update` : téléchargement et remplacement du binaire depuis une release. +- `scaffold` : génération d'un squelette de projet. +- `generate` : génération de code Go depuis `mcp.toml`. + ## Documentation -- Vue d'ensemble : [docs/README.md](docs/README.md) -- Installation et usage type : [docs/getting-started.md](docs/getting-started.md) -- Packages : [docs/packages.md](docs/packages.md) -- Bootstrap CLI : [docs/bootstrap-cli.md](docs/bootstrap-cli.md) -- Manifeste `mcp.toml` : [docs/manifest.md](docs/manifest.md) -- Génération depuis `mcp.toml` : [docs/generate.md](docs/generate.md) -- Scaffolding : [docs/scaffolding.md](docs/scaffolding.md) -- Config JSON : [docs/config.md](docs/config.md) -- Secrets : [docs/secrets.md](docs/secrets.md) -- Helpers CLI : [docs/cli-helpers.md](docs/cli-helpers.md) -- Auto-update : [docs/auto-update.md](docs/auto-update.md) -- Exemple minimal : [docs/minimal-example.md](docs/minimal-example.md) -- Limites actuelles : [docs/limitations.md](docs/limitations.md) +- [Vue d'ensemble](docs/README.md) +- [Installation et utilisation](docs/getting-started.md) +- [Packages](docs/packages.md) +- [Bootstrap CLI](docs/bootstrap-cli.md) +- [Manifeste `mcp.toml`](docs/manifest.md) +- [Génération depuis `mcp.toml`](docs/generate.md) +- [Scaffolding](docs/scaffolding.md) +- [Config JSON](docs/config.md) +- [Secrets](docs/secrets.md) +- [Helpers CLI](docs/cli-helpers.md) +- [Auto-update](docs/auto-update.md) +- [Exemple minimal](docs/minimal-example.md) +- [Limites](docs/limitations.md) diff --git a/docs/README.md b/docs/README.md index 4b0360e..05527ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,8 @@ # Documentation mcp-framework -Cette documentation est organisée par grandes parties pour séparer la vue d'ensemble des détails d'implémentation. +`mcp-framework` fournit des packages Go et un CLI pour construire des binaires +MCP avec une base commune : bootstrap CLI, configuration, secrets, manifeste, +génération de code, scaffold, diagnostic et auto-update. ## Navigation @@ -15,4 +17,4 @@ Cette documentation est organisée par grandes parties pour séparer la vue d'en - [Helpers CLI](cli-helpers.md) - [Auto-update](auto-update.md) - [Exemple minimal](minimal-example.md) -- [Limites actuelles](limitations.md) +- [Limites](limitations.md) diff --git a/docs/auto-update.md b/docs/auto-update.md index afce8e1..52ac5ab 100644 --- a/docs/auto-update.md +++ b/docs/auto-update.md @@ -8,7 +8,7 @@ Le parseur de release supporte : - format `assets.links` (Gitea/GitLab) - format `assets[]` avec `browser_download_url` (GitHub et Gitea API) -Le format attendu pour la réponse `latest release` est actuellement : +Le format attendu pour la réponse `latest release` est : ```json { diff --git a/docs/generate.md b/docs/generate.md index 7f82371..5defc61 100644 --- a/docs/generate.md +++ b/docs/generate.md @@ -5,7 +5,7 @@ racine d'un projet existant. ## Usage -Depuis la racine du projet consommateur : +Depuis la racine du projet Go : ```bash mcp-framework generate @@ -106,22 +106,45 @@ Exemple CI : mcp-framework generate --check ``` -## Migration d'un wrapper manuel +## Utilisation dans l'application -Pour remplacer un wrapper local du type `internal/manifest` : +Importer le package généré depuis le module de l'application : -1. Déplacer le manifeste projet vers `mcp.toml` à la racine. -2. Lancer `mcp-framework generate`. -3. Remplacer les imports du wrapper local par le package généré, par exemple - `example.com/my-mcp/mcpgen`. -4. Remplacer les appels `manifest.Load(...)` du wrapper par - `mcpgen.LoadManifest(...)`. -5. Remplacer les reconstructions locales d'options update par - `mcpgen.UpdateOptions(...)` ou `mcpgen.RunUpdate(...)`. -6. Remplacer les wrappers secret store qui ne font que brancher le loader par - `mcpgen.OpenSecretStore`, `mcpgen.DescribeSecretRuntime` et - `mcpgen.PreflightSecretStore`. -7. Supprimer l'ancien wrapper manuel. +```go +import "example.com/my-mcp/mcpgen" +``` + +Charger le manifeste : + +```go +file, source, err := mcpgen.LoadManifest(".") +if err != nil { + return err +} +_ = file +_ = source +``` + +Construire les options d'update : + +```go +opts, err := mcpgen.UpdateOptions(version, os.Stdout) +if err != nil { + return err +} +``` + +Ouvrir le secret store configuré par le manifeste : + +```go +store, err := mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{ + LookupEnv: os.LookupEnv, +}) +if err != nil { + return err +} +_ = store +``` Après génération, un simple `go build ./...` suffit. La compilation ne dépend pas de la commande `mcp-framework`. diff --git a/docs/getting-started.md b/docs/getting-started.md index b146f73..d4c5dd0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -29,12 +29,20 @@ go run ./cmd/my-mcp help ## Utilisation type -Le flux typique côté application est : +Un flux complet côté application : -1. Déclarer les sous-commandes communes via `bootstrap` (optionnel). -2. Résoudre le profil actif avec `cli`. -3. Charger la config versionnée avec `config`. -4. Lire les secrets avec `secretstore`. -5. Charger le manifest runtime avec `manifest` (`mcp.toml` local, ou fallback embarqué). -6. Exécuter l'auto-update avec `update` si nécessaire. -7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. +1. Déclarer `mcp.toml` à la racine du module. +2. Lancer `mcp-framework generate` pour produire le package `mcpgen`. +3. Déclarer les sous-commandes communes via `bootstrap` si l'application utilise le bootstrap CLI. +4. Résoudre le profil actif avec `cli`. +5. Charger la config versionnée avec `config`. +6. Lire les secrets avec `secretstore` ou `mcpgen.OpenSecretStore`. +7. Charger le manifest runtime avec `mcpgen.LoadManifest`. +8. Exécuter l'auto-update avec `mcpgen.RunUpdate` ou `update.Run`. +9. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. + +Pour vérifier que le code généré est synchronisé avec le manifeste : + +```bash +mcp-framework generate --check +``` diff --git a/docs/limitations.md b/docs/limitations.md index 773309a..915e894 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -1,3 +1,3 @@ -# Limites actuelles +# Limites - l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes diff --git a/docs/minimal-example.md b/docs/minimal-example.md index 2cf682d..6c4c9f5 100644 --- a/docs/minimal-example.md +++ b/docs/minimal-example.md @@ -1,38 +1,63 @@ # Exemple minimal +Cet exemple suppose qu'un `mcp.toml` existe à la racine du module et que le +package généré est à jour : + +```bash +mcp-framework generate +``` + +Exemple de runner Go : + ```go +package main + +import ( + "context" + "fmt" + "os" + + "example.com/my-mcp/mcpgen" + "gitea.lclr.dev/AI/mcp-framework/config" + "gitea.lclr.dev/AI/mcp-framework/update" +) + +var version = "dev" + type Profile struct { BaseURL string `json:"base_url"` } -var embeddedManifest = `...` // fallback utilisé si aucun mcp.toml runtime n'est trouvé +func main() { + if err := run(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} -func run(ctx context.Context, flagProfile string) error { - cfgStore := config.NewStore[Profile]("my-mcp") +func run(ctx context.Context) error { + cfgStore := config.NewStore[Profile](mcpgen.BinaryName) cfg, _, err := cfgStore.LoadDefault() if err != nil { return err } - profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) - profile := cfg.Profiles[profileName] - - manifestFile, _, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest) + _, source, err := mcpgen.LoadManifest(".") if err != nil { return err } + fmt.Println("manifest:", source) - err = update.Run(ctx, update.Options{ - CurrentVersion: version, - BinaryName: "my-mcp", - ReleaseSource: manifestFile.Update.ReleaseSource(), - }) + updateOptions, err := mcpgen.UpdateOptions(version, os.Stdout) if err != nil { return err } + if err := update.Run(ctx, updateOptions); err != nil { + return err + } - _ = profile + _ = cfg return nil } ``` diff --git a/docs/packages.md b/docs/packages.md index beec9cb..83705cd 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -4,6 +4,7 @@ - `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, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. +- `generate` : génération de code Go depuis `mcp.toml` (`mcpgen/manifest.go`, metadata, update, secret store, config fields). - `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage). - `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helpers runtime `OpenFromManifest`, `DescribeRuntime`, `PreflightFromManifest` et formatage homogène via `FormatBackendStatus`. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. diff --git a/docs/scaffolding.md b/docs/scaffolding.md index ce3763f..ee20762 100644 --- a/docs/scaffolding.md +++ b/docs/scaffolding.md @@ -3,7 +3,7 @@ Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : - arborescence recommandée (`cmd//main.go`, `internal/app/app.go`, `mcp.toml`) -- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP) +- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI, setup local et export JSON MCP - wiring initial `bootstrap + config + secretstore + update` - `README.md` de démarrage diff --git a/docs/secrets.md b/docs/secrets.md index 07d527e..b3f97a7 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -56,7 +56,7 @@ Pour imposer KWallet sur Linux : ```go store, err := secretstore.Open(secretstore.Options{ - ServiceName: "email-mcp", + ServiceName: "my-mcp", BackendPolicy: secretstore.BackendKWalletOnly, }) ``` @@ -65,7 +65,7 @@ Pour imposer Bitwarden via son CLI : ```go store, err := secretstore.Open(secretstore.Options{ - ServiceName: "email-mcp", + ServiceName: "my-mcp", BackendPolicy: secretstore.BackendBitwardenCLI, // Optionnel si `bw` n'est pas dans le PATH : // BitwardenCommand: "/usr/local/bin/bw", @@ -96,7 +96,7 @@ et le persister localement (fichier `0600` sous le répertoire de config utilisa ```go session, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ - ServiceName: "email-mcp", + ServiceName: "my-mcp", Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, @@ -111,7 +111,7 @@ Pour réinjecter automatiquement une session persistée dans l'environnement cou ```go loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ - ServiceName: "email-mcp", + ServiceName: "my-mcp", }) if err != nil { return err -- 2.45.2 From e99a1c109ad30be9a91633807abaee567dff7bca Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 14:46:19 +0200 Subject: [PATCH 45/79] docs: design bitwarden cache --- .../2026-05-02-bitwarden-cache-design.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md diff --git a/docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md b/docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md new file mode 100644 index 0000000..2459467 --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md @@ -0,0 +1,217 @@ +# Bitwarden Cache Design + +Date: 2026-05-02 + +## Context + +The `secretstore` package supports a `bitwarden-cli` backend. Each secret read can call the Bitwarden CLI several times: + +- `bw list items --search /` +- `bw get item ` for each candidate item + +These calls can take several seconds when commands such as MCP `setup`, `doctor`, or generated config resolution read multiple secrets or are run repeatedly. + +The framework already requires an unlocked Bitwarden session for this backend and restores a persisted `BW_SESSION` into the process environment when available. The cache must use that runtime session as the trust root without embedding a static key in the binary or repository. + +## Goals + +- Avoid repeated Bitwarden CLI calls for short-lived and long-running framework processes. +- Keep the feature portable across Windows, macOS, Linux, and WSL. +- Enable the encrypted disk cache by default when `BW_SESSION` is available. +- Allow projects and operators to disable the cache. +- Never make cached secrets decryptable with only the installed binary, cache files, and repository content. + +## Non-Goals + +- Protect against an attacker who can read the process memory or environment while the process is running. +- Add an OS keyring dependency for the first implementation. +- Cache non-Bitwarden secret backends. +- Persist decrypted secrets on disk. + +## Configuration + +`manifest.SecretStore` gains: + +```toml +[secret_store] +backend_policy = "bitwarden-cli" +bitwarden_cache = true +``` + +`bitwarden_cache` defaults to `true` when omitted. + +The generated helper options continue to flow through `OpenFromManifest` and `DescribeRuntime`. The runtime option is represented in `secretstore.Options` and `secretstore.OpenFromManifestOptions` so generated packages can still override it programmatically if needed. + +An environment variable can force-disable the cache without editing `mcp.toml`: + +```text +MCP_FRAMEWORK_BITWARDEN_CACHE=0 +``` + +Accepted false values are `0`, `false`, `no`, `off`, and `disabled`, case-insensitive. Any other value leaves the manifest/default behavior in place. + +## Architecture + +The cache is internal to the `bitwarden-cli` backend. + +`bitwardenStore.GetSecret(name)` resolves secrets in this order: + +1. In-memory cache. +2. Encrypted disk cache, only if a cache key can be derived from the effective `BW_SESSION`. +3. Bitwarden CLI lookup. + +After a successful CLI lookup, the secret is written to the memory cache and, when available, the encrypted disk cache. + +`SetSecret` and `DeleteSecret` update Bitwarden first. After success, they invalidate the cache entry for the affected secret. `SetSecret` may repopulate the memory and disk cache with the new value after the Bitwarden write succeeds, but it must not expose a value that failed to persist to Bitwarden. + +## Cache Contents + +Memory entries store: + +- secret value +- expiration timestamp + +Disk entries store encrypted JSON: + +```json +{ + "version": 1, + "service_name": "graylog-mcp", + "secret_name": "profile/prod/api-token", + "scoped_name": "graylog-mcp/profile/prod/api-token", + "created_at": "2026-05-02T10:00:00Z", + "expires_at": "2026-05-02T10:10:00Z", + "value": "secret" +} +``` + +The plaintext exists only before encryption and after decryption in memory. + +## Key Derivation + +Disk cache is enabled only when the effective `BW_SESSION` is non-empty. The effective session is the value available after `EnsureBitwardenSessionEnv`, so it can come from either the process environment or the framework-restored session file. + +The cache derives dedicated keys with HKDF-SHA256: + +```text +master_key = HKDF-SHA256( + input key material: BW_SESSION, + salt: "mcp-framework bitwarden cache salt v1", + info: "mcp-framework bitwarden cache v1" +) +``` + +Separate subkeys are derived from `master_key`: + +- encryption key: `info = "mcp-framework bitwarden cache encryption v1"` +- entry ID key: `info = "mcp-framework bitwarden cache entry id v1"` + +`BW_SESSION` is never used directly as an AES key. + +## Disk Encryption + +Disk entries use AES-256-GCM from the Go standard library. + +Each write generates a fresh random nonce with `crypto/rand`. The file format is JSON with metadata needed to decrypt: + +```json +{ + "version": 1, + "algorithm": "AES-256-GCM", + "nonce": "", + "ciphertext": "" +} +``` + +Authenticated additional data includes stable, non-secret cache context: + +```text +mcp-framework bitwarden cache v1 +service= +secret= +scoped=/ +``` + +If decryption fails, the entry is treated as a cache miss. The framework may remove the unusable file as best effort. + +## Entry Identity + +Disk file names do not expose secret names or Bitwarden item refs. The file name is: + +```text +hex(HMAC-SHA256(entry_id_key, cache_context)) + ".json" +``` + +The cache context includes: + +- cache format version +- service name +- raw secret name +- scoped Bitwarden item name +- backend scope marker + +Because the HMAC key is derived from `BW_SESSION`, changing or losing `BW_SESSION` makes existing file names undiscoverable and existing entries undecryptable. + +## TTL and Invalidation + +Default TTL: 10 minutes. + +TTL applies to both memory and disk entries. Expired entries are treated as misses and may be deleted best-effort. + +The first implementation uses a constant default TTL. It keeps room for a future `bitwarden_cache_ttl` option, but does not add that option now to keep scope tight. + +Invalidation rules: + +- `SetSecret` invalidates the affected entry after the Bitwarden write succeeds. +- `DeleteSecret` invalidates the affected entry after the Bitwarden delete succeeds or confirms the item is already absent. +- `BW_SESSION` change implicitly invalidates disk cache through key derivation. +- `bitwarden_cache = false` disables both memory and disk cache for the backend instance. +- `MCP_FRAMEWORK_BITWARDEN_CACHE=0` disables both memory and disk cache. + +## Storage Location and Permissions + +Disk cache path: + +```text +os.UserCacheDir()/serviceName/bitwarden-cache +``` + +If `os.UserCacheDir` fails, disk cache is disabled and secret reads continue through memory cache and Bitwarden CLI. + +The cache directory is created with `0700` and cache files with `0600` where the platform supports Unix-style permissions. Permission setting errors disable disk cache for that operation rather than failing secret resolution, because Bitwarden remains the source of truth. + +## Error Handling + +Cache failures must not make a healthy Bitwarden backend unusable. + +- Memory cache errors are not expected. +- Disk cache read/decrypt/parse errors are treated as misses. +- Disk cache write errors are ignored after optional debug logging. +- Bitwarden CLI errors keep their current behavior and typed error classification. +- Malformed or expired cache entries are never returned. + +## Tests + +Unit tests should cover: + +- repeated `GetSecret` hits memory and avoids a second CLI item read +- reopened store can read from encrypted disk cache without calling `bw get item` +- disk cache file does not contain the secret value or clear secret name +- cache is disabled by `bitwarden_cache = false` +- cache is disabled by `MCP_FRAMEWORK_BITWARDEN_CACHE=0` +- expired entries are missed and refreshed from Bitwarden +- `SetSecret` invalidates or refreshes stale cache data +- `DeleteSecret` removes cached data +- missing `BW_SESSION` disables disk cache +- changed `BW_SESSION` cannot decrypt a previous disk entry +- manifest parsing preserves the default enabled behavior when the field is omitted + +## Documentation + +Update: + +- `docs/manifest.md` for `[secret_store].bitwarden_cache` +- `docs/secrets.md` for cache behavior, TTL, disable controls, and threat model +- generated/scaffolded `mcp.toml` examples only if the option should be visible by default + +The preferred scaffold output should omit `bitwarden_cache` because the default is enabled. Documentation should show it as the explicit disable knob. -- 2.45.2 From e5f2244ad81e4e30b4b0108185815a29088cd683 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 14:55:52 +0200 Subject: [PATCH 46/79] docs: plan bitwarden cache implementation --- .../plans/2026-05-02-bitwarden-cache.md | 1356 +++++++++++++++++ 1 file changed, 1356 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-02-bitwarden-cache.md diff --git a/docs/superpowers/plans/2026-05-02-bitwarden-cache.md b/docs/superpowers/plans/2026-05-02-bitwarden-cache.md new file mode 100644 index 0000000..e352fed --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-bitwarden-cache.md @@ -0,0 +1,1356 @@ +# Bitwarden Cache Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a secure, portable Bitwarden secret cache with memory-first reads and encrypted disk persistence derived from `BW_SESSION`. + +**Architecture:** The cache is internal to `secretstore` and used only by the `bitwarden-cli` backend. `GetSecret` reads memory, encrypted disk, then Bitwarden CLI; successful CLI reads fill both caches. Manifest and generated helper options carry a default-enabled `bitwarden_cache` option, with an environment override for operational disablement. + +**Tech Stack:** Go 1.25 standard library (`crypto/aes`, `crypto/cipher`, `crypto/hkdf`, `crypto/hmac`, `crypto/rand`, `crypto/sha256`, `encoding/json`, `os`, `time`), existing `manifest`, `secretstore`, and `generate` packages. + +--- + +## File Structure + +- Create `secretstore/bitwarden_cache.go`: cache implementation, key derivation, disk encryption, TTL, path resolution, env override parsing. +- Create `secretstore/bitwarden_cache_test.go`: focused cache tests independent from the Bitwarden fake CLI when possible. +- Modify `secretstore/bitwarden.go`: add cache field to `bitwardenStore`, initialize it in `newBitwardenStore`, and use it in `GetSecret`, `SetSecret`, `DeleteSecret`. +- Modify `secretstore/store.go`: add cache options and defaults to `Options`. +- Modify `secretstore/manifest_open.go`: resolve `manifest.SecretStore.BitwardenCache` into `OpenFromManifestOptions` and `Options`. +- Modify `secretstore/runtime.go`: pass cache option through runtime describe/preflight paths. +- Modify `manifest/manifest.go`: parse and normalize `[secret_store].bitwarden_cache` with tri-state semantics so omission means enabled. +- Modify `manifest/manifest_test.go`: test parsing explicit false and omitted default behavior. +- Modify `secretstore/manifest_open_test.go`: test manifest false disables Bitwarden cache. +- Modify `generate/generate.go`: expose `DisableBitwardenCache` in generated `SecretStoreOptions` and pass it to framework options. +- Modify `generate/generate_test.go`: assert generated helper contains the new option. +- Modify `docs/manifest.md` and `docs/secrets.md`: document cache default, disable controls, TTL, and threat model. + +## Manifest Option Shape + +Use a pointer bool in `manifest.SecretStore` so the code can distinguish omitted from explicit false: + +```go +type SecretStore struct { + BackendPolicy string `toml:"backend_policy"` + BitwardenCache *bool `toml:"bitwarden_cache"` +} +``` + +Use a value bool named `DisableBitwardenCache` in runtime option structs. This preserves default-enabled zero-value behavior while still allowing callers to disable the cache programmatically. The manifest resolver applies `bitwarden_cache = false`, then the runtime disable flag and environment override can force disablement. + +--- + +### Task 1: Manifest Parses `bitwarden_cache` + +**Files:** +- Modify: `manifest/manifest.go` +- Test: `manifest/manifest_test.go` + +- [ ] **Step 1: Write the failing manifest test** + +Add this to `manifest/manifest_test.go`: + +```go +func TestLoadParsesSecretStoreBitwardenCache(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultFile) + + const content = ` +[secret_store] +backend_policy = "bitwarden-cli" +bitwarden_cache = false +` + + 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.SecretStore.BitwardenCache == nil { + t.Fatal("bitwarden cache option is nil, want explicit false pointer") + } + if *file.SecretStore.BitwardenCache { + t.Fatal("bitwarden cache option = true, want false") + } +} + +func TestLoadLeavesOmittedBitwardenCacheUnset(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultFile) + + const content = ` +[secret_store] +backend_policy = "bitwarden-cli" +` + + 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.SecretStore.BitwardenCache != nil { + t.Fatalf("bitwarden cache option = %v, want nil when omitted", *file.SecretStore.BitwardenCache) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +go test ./manifest -run 'TestLoadParsesSecretStoreBitwardenCache|TestLoadLeavesOmittedBitwardenCacheUnset' +``` + +Expected: FAIL because `SecretStore.BitwardenCache` does not exist. + +- [ ] **Step 3: Implement manifest field** + +In `manifest/manifest.go`, change `SecretStore` to: + +```go +type SecretStore struct { + BackendPolicy string `toml:"backend_policy"` + BitwardenCache *bool `toml:"bitwarden_cache"` +} +``` + +Keep `normalize` unchanged except for preserving the pointer: + +```go +func (s *SecretStore) normalize() { + s.BackendPolicy = strings.TrimSpace(s.BackendPolicy) +} +``` + +- [ ] **Step 4: Run manifest tests** + +Run: + +```bash +go test ./manifest -run 'TestLoadParsesSecretStoreBitwardenCache|TestLoadLeavesOmittedBitwardenCacheUnset' +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add manifest/manifest.go manifest/manifest_test.go +git commit -m "feat: parse bitwarden cache manifest option" +``` + +--- + +### Task 2: Add Runtime Cache Option Plumbing + +**Files:** +- Modify: `secretstore/store.go` +- Modify: `secretstore/manifest_open.go` +- Modify: `secretstore/runtime.go` +- Test: `secretstore/manifest_open_test.go` + +- [ ] **Step 1: Write failing manifest plumbing test** + +Add this to `secretstore/manifest_open_test.go`: + +```go +func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) { + cacheDisabled := false + resolution, err := resolveManifestPolicy(OpenFromManifestOptions{ + ServiceName: "email-mcp", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{ + BackendPolicy: string(BackendBitwardenCLI), + BitwardenCache: &cacheDisabled, + }, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("resolveManifestPolicy returned error: %v", err) + } + if resolution.BitwardenCache { + t.Fatal("resolution BitwardenCache = true, want false") + } + if resolution.Policy != BackendBitwardenCLI { + t.Fatalf("resolution policy = %q, want %q", resolution.Policy, BackendBitwardenCLI) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +go test ./secretstore -run TestResolveManifestPolicyPreservesBitwardenCacheDisable +``` + +Expected: FAIL because cache option fields are not wired. + +- [ ] **Step 3: Add option fields** + +In `secretstore/store.go`, update `Options`: + +```go +type Options struct { + ServiceName string + BackendPolicy BackendPolicy + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + BitwardenDebug bool + DisableBitwardenCache bool + Shell string +} +``` + +In `secretstore/manifest_open.go`, update `OpenFromManifestOptions`: + +```go +type OpenFromManifestOptions struct { + ServiceName string + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + BitwardenDebug bool + DisableBitwardenCache bool + Shell string + ManifestLoader ManifestLoader + ExecutableResolver ExecutableResolver +} +``` + +Add a field to `manifestPolicyResolution`: + +```go +type manifestPolicyResolution struct { + Policy BackendPolicy + Source string + BitwardenCache bool +} +``` + +Where `resolveManifestPolicy` returns missing-manifest or omitted-field defaults, set `BitwardenCache: true`. When manifest has an explicit pointer, use it: + +```go +bitwardenCache := true +if file.SecretStore.BitwardenCache != nil { + bitwardenCache = *file.SecretStore.BitwardenCache +} +``` + +Pass it in `OpenFromManifest`: + +```go +DisableBitwardenCache: options.DisableBitwardenCache || !manifestPolicy.BitwardenCache, +``` + +This keeps `OpenFromManifestOptions{}` default-enabled when the manifest omits the field, while still respecting both `bitwarden_cache = false` and a caller's explicit disable flag. If a helper is clearer, add: + +```go +func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool { + return runtimeDisabled || !manifestEnabled +} +``` + +Call it as: + +```go +DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, manifestPolicy.BitwardenCache), +``` + +- [ ] **Step 4: Pass through runtime describe options** + +In `secretstore/runtime.go`, add `DisableBitwardenCache bool` to `DescribeRuntimeOptions`, and pass it to `Open`. + +Use: + +```go +DisableBitwardenCache: options.DisableBitwardenCache, +``` + +- [ ] **Step 5: Run plumbing test** + +Run: + +```bash +go test ./secretstore -run TestResolveManifestPolicyPreservesBitwardenCacheDisable +``` + +Expected: PASS. + +- [ ] **Step 6: Commit compile-safe plumbing** + +Run the broader compile target first: + +```bash +go test ./secretstore ./manifest +``` + +Expected: PASS. + +Then run: + +```bash +git add secretstore/store.go secretstore/manifest_open.go secretstore/runtime.go secretstore/manifest_open_test.go +git commit -m "feat: wire bitwarden cache options" +``` + +--- + +### Task 3: Implement Cache Core + +**Files:** +- Create: `secretstore/bitwarden_cache.go` +- Create: `secretstore/bitwarden_cache_test.go` + +- [ ] **Step 1: Write cache core tests** + +Create `secretstore/bitwarden_cache_test.go`: + +```go +package secretstore + +import ( + "bytes" + "os" + "strings" + "testing" + "time" +) + +func TestBitwardenCacheMemoryHit(t *testing.T) { + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: 10 * time.Minute, + Now: func() time.Time { return time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) }, + CacheDir: t.TempDir(), + Enabled: true, + }) + + cache.store("api-token", "email-mcp/api-token", "secret-v1") + got, ok := cache.loadMemory("api-token", "email-mcp/api-token") + if !ok { + t.Fatal("memory cache miss, want hit") + } + if got != "secret-v1" { + t.Fatalf("memory cache value = %q, want secret-v1", got) + } +} + +func TestBitwardenCacheDiskRoundTripIsEncrypted(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: 10 * time.Minute, + Now: func() time.Time { return now }, + CacheDir: dir, + Enabled: true, + }) + + cache.store("api-token", "email-mcp/api-token", "secret-v1") + + reopened := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: 10 * time.Minute, + Now: func() time.Time { return now.Add(time.Minute) }, + CacheDir: dir, + Enabled: true, + }) + got, ok := reopened.loadDisk("api-token", "email-mcp/api-token") + if !ok { + t.Fatal("disk cache miss, want hit") + } + if got != "secret-v1" { + t.Fatalf("disk cache value = %q, want secret-v1", got) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir cache dir: %v", err) + } + if len(entries) != 1 { + t.Fatalf("cache file count = %d, want 1", len(entries)) + } + data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) + if err != nil { + t.Fatalf("ReadFile cache file: %v", err) + } + if bytes.Contains(data, []byte("secret-v1")) { + t.Fatalf("cache file contains plaintext secret: %s", data) + } + if strings.Contains(entries[0].Name(), "api-token") { + t.Fatalf("cache file name exposes secret name: %s", entries[0].Name()) + } +} + +func TestBitwardenCacheRejectsChangedSession(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: 10 * time.Minute, + Now: func() time.Time { return now }, + CacheDir: dir, + Enabled: true, + }) + cache.store("api-token", "email-mcp/api-token", "secret-v1") + + changed := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v2", + TTL: 10 * time.Minute, + Now: func() time.Time { return now.Add(time.Minute) }, + CacheDir: dir, + Enabled: true, + }) + if got, ok := changed.loadDisk("api-token", "email-mcp/api-token"); ok { + t.Fatalf("disk cache hit with changed session = %q, want miss", got) + } +} + +func TestBitwardenCacheExpiresEntries(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: time.Minute, + Now: func() time.Time { return now }, + CacheDir: dir, + Enabled: true, + }) + cache.store("api-token", "email-mcp/api-token", "secret-v1") + + expired := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: time.Minute, + Now: func() time.Time { return now.Add(2 * time.Minute) }, + CacheDir: dir, + Enabled: true, + }) + if got, ok := expired.load("api-token", "email-mcp/api-token"); ok { + t.Fatalf("expired cache hit = %q, want miss", got) + } +} +``` + +If `filepath` is missing, add it to the imports. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +go test ./secretstore -run 'TestBitwardenCache' +``` + +Expected: FAIL because cache types/functions do not exist. + +- [ ] **Step 3: Implement cache core** + +Create `secretstore/bitwarden_cache.go`: + +```go +package secretstore + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + bitwardenCacheEnvName = "MCP_FRAMEWORK_BITWARDEN_CACHE" + defaultBitwardenCacheTTL = 10 * time.Minute + bitwardenCacheFormatVersion = 1 + bitwardenCacheAlgorithm = "AES-256-GCM" + bitwardenCacheDirName = "bitwarden-cache" + bitwardenCacheSalt = "mcp-framework bitwarden cache salt v1" + bitwardenCacheInfo = "mcp-framework bitwarden cache v1" + bitwardenCacheEncryptionInfo = "mcp-framework bitwarden cache encryption v1" + bitwardenCacheEntryIDInfo = "mcp-framework bitwarden cache entry id v1" + bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1" +) + +type bitwardenCacheOptions struct { + ServiceName string + Session string + TTL time.Duration + Now func() time.Time + CacheDir string + Enabled bool +} + +type bitwardenCache struct { + mu sync.Mutex + enabled bool + serviceName string + ttl time.Duration + now func() time.Time + cacheDir string + encryptionKey []byte + entryIDKey []byte + memory map[string]bitwardenCacheMemoryEntry +} + +type bitwardenCacheMemoryEntry struct { + value string + expiresAt time.Time +} + +type bitwardenCachePlaintext struct { + Version int `json:"version"` + ServiceName string `json:"service_name"` + SecretName string `json:"secret_name"` + ScopedName string `json:"scoped_name"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Value string `json:"value"` +} + +type bitwardenCacheEnvelope struct { + Version int `json:"version"` + Algorithm string `json:"algorithm"` + Nonce string `json:"nonce"` + Ciphertext string `json:"ciphertext"` +} + +func newBitwardenCache(options bitwardenCacheOptions) *bitwardenCache { + now := options.Now + if now == nil { + now = time.Now + } + ttl := options.TTL + if ttl <= 0 { + ttl = defaultBitwardenCacheTTL + } + + cache := &bitwardenCache{ + enabled: options.Enabled, + serviceName: strings.TrimSpace(options.ServiceName), + ttl: ttl, + now: now, + cacheDir: strings.TrimSpace(options.CacheDir), + memory: map[string]bitwardenCacheMemoryEntry{}, + } + if !cache.enabled { + return cache + } + + session := strings.TrimSpace(options.Session) + if session == "" { + return cache + } + masterKey, err := hkdf.Key(sha256.New, []byte(session), []byte(bitwardenCacheSalt), bitwardenCacheInfo, 32) + if err != nil { + return cache + } + cache.encryptionKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEncryptionInfo, 32) + if err != nil { + cache.encryptionKey = nil + return cache + } + cache.entryIDKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEntryIDInfo, 32) + if err != nil { + cache.encryptionKey = nil + cache.entryIDKey = nil + return cache + } + return cache +} + +func (c *bitwardenCache) load(secretName, scopedName string) (string, bool) { + if value, ok := c.loadMemory(secretName, scopedName); ok { + return value, true + } + return c.loadDisk(secretName, scopedName) +} + +func (c *bitwardenCache) store(secretName, scopedName, value string) { + if c == nil || !c.enabled { + return + } + c.storeMemory(secretName, scopedName, value) + c.storeDisk(secretName, scopedName, value) +} + +func (c *bitwardenCache) invalidate(secretName, scopedName string) { + if c == nil { + return + } + key := c.memoryKey(secretName, scopedName) + c.mu.Lock() + delete(c.memory, key) + c.mu.Unlock() + if path, ok := c.entryPath(secretName, scopedName); ok { + _ = os.Remove(path) + } +} + +func (c *bitwardenCache) loadMemory(secretName, scopedName string) (string, bool) { + if c == nil || !c.enabled { + return "", false + } + key := c.memoryKey(secretName, scopedName) + now := c.now() + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.memory[key] + if !ok { + return "", false + } + if !entry.expiresAt.After(now) { + delete(c.memory, key) + return "", false + } + return entry.value, true +} + +func (c *bitwardenCache) storeMemory(secretName, scopedName, value string) { + key := c.memoryKey(secretName, scopedName) + c.mu.Lock() + c.memory[key] = bitwardenCacheMemoryEntry{ + value: value, + expiresAt: c.now().Add(c.ttl), + } + c.mu.Unlock() +} + +func (c *bitwardenCache) loadDisk(secretName, scopedName string) (string, bool) { + path, ok := c.entryPath(secretName, scopedName) + if !ok { + return "", false + } + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + var envelope bitwardenCacheEnvelope + if err := json.Unmarshal(data, &envelope); err != nil { + _ = os.Remove(path) + return "", false + } + plaintext, err := c.decryptEnvelope(secretName, scopedName, envelope) + if err != nil { + _ = os.Remove(path) + return "", false + } + if plaintext.Version != bitwardenCacheFormatVersion || + plaintext.ServiceName != c.serviceName || + plaintext.SecretName != strings.TrimSpace(secretName) || + plaintext.ScopedName != strings.TrimSpace(scopedName) || + !plaintext.ExpiresAt.After(c.now()) { + _ = os.Remove(path) + return "", false + } + c.storeMemory(secretName, scopedName, plaintext.Value) + return plaintext.Value, true +} + +func (c *bitwardenCache) storeDisk(secretName, scopedName, value string) { + if c.cacheDir == "" || len(c.encryptionKey) == 0 || len(c.entryIDKey) == 0 { + return + } + path, ok := c.entryPath(secretName, scopedName) + if !ok { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return + } + _ = os.Chmod(filepath.Dir(path), 0o700) + + now := c.now() + plaintext := bitwardenCachePlaintext{ + Version: bitwardenCacheFormatVersion, + ServiceName: c.serviceName, + SecretName: strings.TrimSpace(secretName), + ScopedName: strings.TrimSpace(scopedName), + CreatedAt: now, + ExpiresAt: now.Add(c.ttl), + Value: value, + } + envelope, err := c.encryptPlaintext(secretName, scopedName, plaintext) + if err != nil { + return + } + data, err := json.Marshal(envelope) + if err != nil { + return + } + tmp, err := os.CreateTemp(filepath.Dir(path), "bitwarden-cache-*.tmp") + if err != nil { + return + } + tmpPath := tmp.Name() + cleanup := true + defer func() { + _ = tmp.Close() + if cleanup { + _ = os.Remove(tmpPath) + } + }() + _ = tmp.Chmod(0o600) + if _, err := tmp.Write(data); err != nil { + return + } + if err := tmp.Close(); err != nil { + return + } + if err := os.Rename(tmpPath, path); err != nil { + return + } + _ = os.Chmod(path, 0o600) + cleanup = false +} + +func (c *bitwardenCache) encryptPlaintext(secretName, scopedName string, plaintext bitwardenCachePlaintext) (bitwardenCacheEnvelope, error) { + raw, err := json.Marshal(plaintext) + if err != nil { + return bitwardenCacheEnvelope{}, err + } + block, err := aes.NewCipher(c.encryptionKey) + if err != nil { + return bitwardenCacheEnvelope{}, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return bitwardenCacheEnvelope{}, err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return bitwardenCacheEnvelope{}, err + } + ciphertext := aead.Seal(nil, nonce, raw, c.additionalData(secretName, scopedName)) + return bitwardenCacheEnvelope{ + Version: bitwardenCacheFormatVersion, + Algorithm: bitwardenCacheAlgorithm, + Nonce: base64.StdEncoding.EncodeToString(nonce), + Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), + }, nil +} + +func (c *bitwardenCache) decryptEnvelope(secretName, scopedName string, envelope bitwardenCacheEnvelope) (bitwardenCachePlaintext, error) { + if envelope.Version != bitwardenCacheFormatVersion || envelope.Algorithm != bitwardenCacheAlgorithm { + return bitwardenCachePlaintext{}, errors.New("unsupported bitwarden cache envelope") + } + nonce, err := base64.StdEncoding.DecodeString(envelope.Nonce) + if err != nil { + return bitwardenCachePlaintext{}, err + } + ciphertext, err := base64.StdEncoding.DecodeString(envelope.Ciphertext) + if err != nil { + return bitwardenCachePlaintext{}, err + } + block, err := aes.NewCipher(c.encryptionKey) + if err != nil { + return bitwardenCachePlaintext{}, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return bitwardenCachePlaintext{}, err + } + raw, err := aead.Open(nil, nonce, ciphertext, c.additionalData(secretName, scopedName)) + if err != nil { + return bitwardenCachePlaintext{}, err + } + var plaintext bitwardenCachePlaintext + if err := json.Unmarshal(raw, &plaintext); err != nil { + return bitwardenCachePlaintext{}, err + } + return plaintext, nil +} + +func (c *bitwardenCache) entryPath(secretName, scopedName string) (string, bool) { + if c == nil || !c.enabled || c.cacheDir == "" || len(c.entryIDKey) == 0 { + return "", false + } + mac := hmac.New(sha256.New, c.entryIDKey) + _, _ = mac.Write([]byte(c.cacheContext(secretName, scopedName))) + return filepath.Join(c.cacheDir, hex.EncodeToString(mac.Sum(nil))+".json"), true +} + +func (c *bitwardenCache) memoryKey(secretName, scopedName string) string { + return c.cacheContext(secretName, scopedName) +} + +func (c *bitwardenCache) additionalData(secretName, scopedName string) []byte { + return []byte(fmt.Sprintf( + "mcp-framework bitwarden cache v1\nservice=%s\nsecret=%s\nscoped=%s", + c.serviceName, + strings.TrimSpace(secretName), + strings.TrimSpace(scopedName), + )) +} + +func (c *bitwardenCache) cacheContext(secretName, scopedName string) string { + return fmt.Sprintf( + "version=%d\nservice=%s\nsecret=%s\nscoped=%s\nscope=%s", + bitwardenCacheFormatVersion, + c.serviceName, + strings.TrimSpace(secretName), + strings.TrimSpace(scopedName), + bitwardenCacheContextScope, + ) +} + +func resolveBitwardenCacheDir(serviceName string) string { + cacheRoot, err := os.UserCacheDir() + if err != nil { + return "" + } + return filepath.Join(cacheRoot, strings.TrimSpace(serviceName), bitwardenCacheDirName) +} + +func bitwardenCacheDisabledByEnv() bool { + raw, ok := os.LookupEnv(bitwardenCacheEnvName) + if !ok { + return false + } + switch strings.ToLower(strings.TrimSpace(raw)) { + case "0", "false", "no", "off", "disabled": + return true + default: + return false + } +} +``` + +- [ ] **Step 4: Fix test imports** + +Ensure `secretstore/bitwarden_cache_test.go` imports `path/filepath` because `TestBitwardenCacheDiskRoundTripIsEncrypted` uses it. + +- [ ] **Step 5: Run cache core tests** + +Run: + +```bash +go test ./secretstore -run 'TestBitwardenCache' +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add secretstore/bitwarden_cache.go secretstore/bitwarden_cache_test.go +git commit -m "feat: add encrypted bitwarden cache core" +``` + +--- + +### Task 4: Integrate Cache With Bitwarden Store + +**Files:** +- Modify: `secretstore/bitwarden.go` +- Modify: `secretstore/bitwarden_test.go` +- Modify: `secretstore/manifest_open_test.go` + +- [ ] **Step 1: Add integration tests** + +Add these tests to `secretstore/bitwarden_test.go`: + +```go +func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + for i := 0; i < 2; i++ { + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret #%d returned error: %v", i+1, err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) + } + } + if fakeCLI.getItemCalls != 1 { + t.Fatalf("bw get item count = %d, want 1 with memory cache", fakeCLI.getItemCalls) + } +} + +func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) { + withBitwardenSession(t) + t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0") + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + for i := 0; i < 2; i++ { + if _, err := store.GetSecret("api-token"); err != nil { + t.Fatalf("GetSecret #%d returned error: %v", i+1, err) + } + } + if fakeCLI.getItemCalls != 2 { + t.Fatalf("bw get item count = %d, want 2 when env disables cache", fakeCLI.getItemCalls) + } +} +``` + +Add this test to `secretstore/manifest_open_test.go`: + +```go +func TestOpenFromManifestAppliesBitwardenCacheDisable(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + cacheDisabled := false + store, err := OpenFromManifest(OpenFromManifestOptions{ + ServiceName: "email-mcp", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{ + BackendPolicy: string(BackendBitwardenCLI), + BitwardenCache: &cacheDisabled, + }, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("OpenFromManifest returned error: %v", err) + } + + for i := 0; i < 2; i++ { + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret #%d returned error: %v", i+1, err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) + } + } + if fakeCLI.getItemCalls != 2 { + t.Fatalf("bw get item count = %d, want 2 when manifest disables cache", fakeCLI.getItemCalls) + } +} +``` + +- [ ] **Step 2: Run integration tests to verify failure** + +Run: + +```bash +go test ./secretstore -run 'TestBitwardenStoreGetSecretUsesMemoryCache|TestBitwardenStoreCacheDisabledByEnv|TestOpenFromManifestAppliesBitwardenCacheDisable' +``` + +Expected: FAIL until `bitwardenStore` uses the cache. + +- [ ] **Step 3: Add cache field and initialization** + +In `secretstore/bitwarden.go`, update `bitwardenStore`: + +```go +type bitwardenStore struct { + command string + serviceName string + debug bool + cache *bitwardenCache +} +``` + +In `newBitwardenStore`, after `EnsureBitwardenSessionEnv`, read the effective session: + +```go +session, _ := os.LookupEnv(bitwardenSessionEnvName) +cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv() +``` + +When constructing the store: + +```go +store := &bitwardenStore{ + command: command, + serviceName: serviceName, + debug: debugEnabled, + cache: newBitwardenCache(bitwardenCacheOptions{ + ServiceName: serviceName, + Session: session, + TTL: defaultBitwardenCacheTTL, + CacheDir: resolveBitwardenCacheDir(serviceName), + Enabled: cacheEnabled, + }), +} +``` + +Make sure this happens after session restoration, not before. If needed, move the `store := &bitwardenStore{...}` block below the `EnsureBitwardenSessionEnv` call. + +- [ ] **Step 4: Use cache in `GetSecret`** + +Change `GetSecret`: + +```go +func (s *bitwardenStore) GetSecret(name string) (string, error) { + secretName := s.scopedName(name) + if s.cache != nil { + if secret, ok := s.cache.load(name, secretName); ok { + return secret, nil + } + } + + _, payload, err := s.findItem(secretName, name) + if err != nil { + return "", err + } + + secret, ok := readBitwardenSecret(payload) + if !ok { + return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName) + } + + if s.cache != nil { + s.cache.store(name, secretName, secret) + } + return secret, nil +} +``` + +- [ ] **Step 5: Invalidate on writes and deletes** + +After successful create/edit in `SetSecret`, call: + +```go +if s.cache != nil { + s.cache.invalidate(name, secretName) + s.cache.store(name, secretName, secret) +} +``` + +After successful delete or not-found in `DeleteSecret`, call: + +```go +if s.cache != nil { + s.cache.invalidate(name, secretName) +} +``` + +- [ ] **Step 6: Run integration tests** + +Run: + +```bash +go test ./secretstore -run 'TestBitwardenStoreGetSecretUsesMemoryCache|TestBitwardenStoreCacheDisabledByEnv|TestOpenFromManifestAppliesBitwardenCacheDisable' +``` + +Expected: PASS. + +- [ ] **Step 7: Run all secretstore tests** + +Run: + +```bash +go test ./secretstore +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +Run: + +```bash +git add secretstore/bitwarden.go secretstore/bitwarden_test.go secretstore/manifest_open_test.go +git commit -m "feat: cache bitwarden secret reads" +``` + +--- + +### Task 5: Generated Helper Support + +**Files:** +- Modify: `generate/generate.go` +- Modify: `generate/generate_test.go` + +- [ ] **Step 1: Write failing generated helper assertion** + +In `generate/generate_test.go`, add these expected snippets to the test that checks generated secret store helper content: + +```go +"DisableBitwardenCache bool", +"DisableBitwardenCache: options.DisableBitwardenCache,", +``` + +If there is no focused assertion list, add a test: + +```go +func TestGenerateSecretStoreIncludesBitwardenCacheOption(t *testing.T) { + projectDir := newProject(t, ` +binary_name = "demo-mcp" + +[secret_store] +backend_policy = "bitwarden-cli" +`) + + if _, err := Generate(Options{ProjectDir: projectDir}); err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go")) + if err != nil { + t.Fatalf("ReadFile generated secretstore: %v", err) + } + text := string(content) + for _, snippet := range []string{ + "DisableBitwardenCache bool", + "DisableBitwardenCache: options.DisableBitwardenCache,", + } { + if !strings.Contains(text, snippet) { + t.Fatalf("generated secretstore.go missing %q:\n%s", snippet, text) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +go test ./generate -run TestGenerateSecretStoreIncludesBitwardenCacheOption +``` + +Expected: FAIL because generated code lacks `DisableBitwardenCache`. + +- [ ] **Step 3: Update generator template** + +In `generate/generate.go`, add to generated `SecretStoreOptions`: + +```go +DisableBitwardenCache bool +``` + +Pass to `OpenFromManifestOptions` and `DescribeRuntimeOptions`: + +```go +DisableBitwardenCache: options.DisableBitwardenCache, +``` + +- [ ] **Step 4: Run generator test** + +Run: + +```bash +go test ./generate -run TestGenerateSecretStoreIncludesBitwardenCacheOption +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add generate/generate.go generate/generate_test.go +git commit -m "feat: expose bitwarden cache in generated helpers" +``` + +--- + +### Task 6: Documentation + +**Files:** +- Modify: `docs/manifest.md` +- Modify: `docs/secrets.md` + +- [ ] **Step 1: Update manifest docs** + +In `docs/manifest.md`, update the `[secret_store]` example: + +```toml +[secret_store] +backend_policy = "auto" +# Optionnel: mettre false pour désactiver le cache Bitwarden. +bitwarden_cache = true +``` + +Add field description: + +```markdown +- `[secret_store].bitwarden_cache` : active le cache Bitwarden mémoire + disque chiffré quand `backend_policy = "bitwarden-cli"`. Par défaut, le cache est activé si le champ est absent. Mettre `false` pour le désactiver. +``` + +- [ ] **Step 2: Update secrets docs** + +In `docs/secrets.md`, after the Bitwarden backend section, add this Markdown: + +````markdown +## Cache Bitwarden + +Le backend `bitwarden-cli` met en cache les lectures de secrets par défaut. +Le cache mémoire évite les appels répétés au CLI dans un même process. Le cache +disque est chiffré avec une clé dérivée de `BW_SESSION` via HKDF-SHA256 et AES-GCM. + +TTL par défaut : 10 minutes. + +Pour désactiver le cache dans `mcp.toml` : + +```toml +[secret_store] +backend_policy = "bitwarden-cli" +bitwarden_cache = false +``` + +Pour le désactiver sans modifier le manifeste : + +```bash +MCP_FRAMEWORK_BITWARDEN_CACHE=0 +``` + +Le fichier de cache et le binaire installé ne suffisent pas à déchiffrer les +secrets. Si `BW_SESSION` change ou disparaît, les entrées disque existantes +deviennent inutilisables. Cette protection ne couvre pas un attaquant qui peut +lire l'environnement ou la mémoire du process pendant l'exécution. +```` + +- [ ] **Step 3: Run docs grep** + +Run: + +```bash +rg -n "bitwarden_cache|MCP_FRAMEWORK_BITWARDEN_CACHE|Cache Bitwarden" docs +``` + +Expected: shows entries in `docs/manifest.md` and `docs/secrets.md`. + +- [ ] **Step 4: Commit** + +Run: + +```bash +git add docs/manifest.md docs/secrets.md +git commit -m "docs: document bitwarden cache controls" +``` + +--- + +### Task 7: Final Validation + +**Files:** +- No planned code edits unless validation exposes a defect. + +- [ ] **Step 1: Run targeted package tests** + +Run: + +```bash +go test ./manifest ./secretstore ./generate +``` + +Expected: PASS. + +- [ ] **Step 2: Run full test suite** + +Run: + +```bash +go test ./... +``` + +Expected: PASS. + +- [ ] **Step 3: Inspect git status** + +Run: + +```bash +git status --short +``` + +Expected: clean working tree. + +- [ ] **Step 4: If validation changed files, commit fixes** + +If any fixes were needed: + +```bash +git add +git commit -m "fix: stabilize bitwarden cache" +``` + +If no fixes were needed, do not create an empty commit. + +--- + +## Self-Review Notes + +- Spec coverage: manifest default and disable path are covered by Tasks 1, 2, and 6; secure cache core is covered by Task 3; store integration and invalidation are covered by Task 4; generated helper support is covered by Task 5; docs are covered by Task 6; validation is covered by Task 7. +- Red-flag scan: no incomplete markers or open-ended “add tests” steps remain. +- Type consistency: `BitwardenCache` is the manifest field, `DisableBitwardenCache` is the runtime/generated option, `bitwardenCache` is the internal cache type, and `bitwardenCacheEnvName` is the env constant. -- 2.45.2 From 9675490cd3c231271bb624614faedb88d8c5fe0c Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 14:57:52 +0200 Subject: [PATCH 47/79] feat: parse bitwarden cache manifest option --- manifest/manifest.go | 3 ++- manifest/manifest_test.go | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/manifest/manifest.go b/manifest/manifest.go index 59c1b88..d82acb3 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -49,7 +49,8 @@ type Environment struct { } type SecretStore struct { - BackendPolicy string `toml:"backend_policy"` + BackendPolicy string `toml:"backend_policy"` + BitwardenCache *bool `toml:"bitwarden_cache"` } type Profiles struct { diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 087b006..6b18139 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -235,6 +235,54 @@ description = " Client MCP interne " } } +func TestLoadParsesSecretStoreBitwardenCache(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultFile) + + const content = ` +[secret_store] +backend_policy = "bitwarden-cli" +bitwarden_cache = false +` + + 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.SecretStore.BitwardenCache == nil { + t.Fatal("bitwarden cache option is nil, want explicit false pointer") + } + if *file.SecretStore.BitwardenCache { + t.Fatal("bitwarden cache option = true, want false") + } +} + +func TestLoadLeavesOmittedBitwardenCacheUnset(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultFile) + + const content = ` +[secret_store] +backend_policy = "bitwarden-cli" +` + + 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.SecretStore.BitwardenCache != nil { + t.Fatalf("bitwarden cache option = %v, want nil when omitted", *file.SecretStore.BitwardenCache) + } +} + func TestLoadParsesConfigFields(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, DefaultFile) -- 2.45.2 From 1a44a2ea35fbf358085c1bd127456bcdd5e308f7 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 14:59:04 +0200 Subject: [PATCH 48/79] feat: wire bitwarden cache options --- secretstore/manifest_open.go | 65 +++++++++++++++++++------------ secretstore/manifest_open_test.go | 27 +++++++++++++ secretstore/runtime.go | 53 +++++++++++++------------ secretstore/store.go | 17 ++++---- 4 files changed, 104 insertions(+), 58 deletions(-) diff --git a/secretstore/manifest_open.go b/secretstore/manifest_open.go index f8009d4..4b0051b 100644 --- a/secretstore/manifest_open.go +++ b/secretstore/manifest_open.go @@ -15,15 +15,16 @@ type ManifestLoader func(startDir string) (manifest.File, string, error) type ExecutableResolver func() (string, error) type OpenFromManifestOptions struct { - ServiceName string - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string - BitwardenCommand string - BitwardenDebug bool - Shell string - ManifestLoader ManifestLoader - ExecutableResolver ExecutableResolver + ServiceName string + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + BitwardenDebug bool + DisableBitwardenCache bool + Shell string + ManifestLoader ManifestLoader + ExecutableResolver ExecutableResolver } func OpenFromManifest(options OpenFromManifestOptions) (Store, error) { @@ -33,14 +34,15 @@ func OpenFromManifest(options OpenFromManifestOptions) (Store, error) { } return Open(Options{ - ServiceName: options.ServiceName, - BackendPolicy: manifestPolicy.Policy, - LookupEnv: options.LookupEnv, - KWalletAppID: options.KWalletAppID, - KWalletFolder: options.KWalletFolder, - BitwardenCommand: strings.TrimSpace(options.BitwardenCommand), - BitwardenDebug: options.BitwardenDebug, - Shell: strings.TrimSpace(options.Shell), + ServiceName: options.ServiceName, + BackendPolicy: manifestPolicy.Policy, + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + BitwardenCommand: strings.TrimSpace(options.BitwardenCommand), + BitwardenDebug: options.BitwardenDebug, + DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, manifestPolicy.BitwardenCache), + Shell: strings.TrimSpace(options.Shell), }) } @@ -53,8 +55,9 @@ func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolic } type manifestPolicyResolution struct { - Policy BackendPolicy - Source string + Policy BackendPolicy + Source string + BitwardenCache bool } func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) { @@ -82,17 +85,24 @@ func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResol if err != nil { if errors.Is(err, os.ErrNotExist) { return manifestPolicyResolution{ - Policy: BackendAuto, - Source: "", + Policy: BackendAuto, + Source: "", + BitwardenCache: true, }, nil } return manifestPolicyResolution{}, fmt.Errorf("load runtime manifest from %q: %w", startDir, err) } + bitwardenCache := true + if file.SecretStore.BitwardenCache != nil { + bitwardenCache = *file.SecretStore.BitwardenCache + } + if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" { return manifestPolicyResolution{ - Policy: BackendAuto, - Source: strings.TrimSpace(manifestPath), + Policy: BackendAuto, + Source: strings.TrimSpace(manifestPath), + BitwardenCache: bitwardenCache, }, nil } @@ -106,7 +116,12 @@ func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResol } return manifestPolicyResolution{ - Policy: policy, - Source: strings.TrimSpace(manifestPath), + Policy: policy, + Source: strings.TrimSpace(manifestPath), + BitwardenCache: bitwardenCache, }, nil } + +func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool { + return runtimeDisabled || !manifestEnabled +} diff --git a/secretstore/manifest_open_test.go b/secretstore/manifest_open_test.go index bfe783a..0a92d37 100644 --- a/secretstore/manifest_open_test.go +++ b/secretstore/manifest_open_test.go @@ -111,6 +111,33 @@ func TestOpenFromManifestReturnsExplicitErrorForInvalidManifestPolicy(t *testing } } +func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) { + cacheDisabled := false + resolution, err := resolveManifestPolicy(OpenFromManifestOptions{ + ServiceName: "email-mcp", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{ + BackendPolicy: string(BackendBitwardenCLI), + BitwardenCache: &cacheDisabled, + }, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("resolveManifestPolicy returned error: %v", err) + } + if resolution.BitwardenCache { + t.Fatal("resolution BitwardenCache = true, want false") + } + if resolution.Policy != BackendBitwardenCLI { + t.Fatalf("resolution policy = %q, want %q", resolution.Policy, BackendBitwardenCLI) + } +} + func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) { execErr := errors.New("boom") _, err := OpenFromManifest(OpenFromManifestOptions{ diff --git a/secretstore/runtime.go b/secretstore/runtime.go index 4d342d7..f0894a9 100644 --- a/secretstore/runtime.go +++ b/secretstore/runtime.go @@ -9,15 +9,16 @@ import ( const DefaultManifestSource = "default:auto (manifest not found)" type DescribeRuntimeOptions struct { - ServiceName string - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string - BitwardenCommand string - BitwardenDebug bool - Shell string - ManifestLoader ManifestLoader - ExecutableResolver ExecutableResolver + ServiceName string + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + BitwardenDebug bool + DisableBitwardenCache bool + Shell string + ManifestLoader ManifestLoader + ExecutableResolver ExecutableResolver } type RuntimeDescription struct { @@ -47,14 +48,15 @@ type PreflightReport struct { func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) { resolution, err := resolveManifestPolicy(OpenFromManifestOptions{ - ServiceName: options.ServiceName, - LookupEnv: options.LookupEnv, - KWalletAppID: options.KWalletAppID, - KWalletFolder: options.KWalletFolder, - BitwardenCommand: options.BitwardenCommand, - Shell: options.Shell, - ManifestLoader: options.ManifestLoader, - ExecutableResolver: options.ExecutableResolver, + ServiceName: options.ServiceName, + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + BitwardenCommand: options.BitwardenCommand, + DisableBitwardenCache: options.DisableBitwardenCache, + Shell: options.Shell, + ManifestLoader: options.ManifestLoader, + ExecutableResolver: options.ExecutableResolver, }) if err != nil { return RuntimeDescription{}, err @@ -68,14 +70,15 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) } store, openErr := Open(Options{ - ServiceName: options.ServiceName, - BackendPolicy: resolution.Policy, - LookupEnv: options.LookupEnv, - KWalletAppID: options.KWalletAppID, - KWalletFolder: options.KWalletFolder, - BitwardenCommand: options.BitwardenCommand, - BitwardenDebug: options.BitwardenDebug, - Shell: options.Shell, + ServiceName: options.ServiceName, + BackendPolicy: resolution.Policy, + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + BitwardenCommand: options.BitwardenCommand, + BitwardenDebug: options.BitwardenDebug, + DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, resolution.BitwardenCache), + Shell: options.Shell, }) if openErr != nil { desc.Ready = false diff --git a/secretstore/store.go b/secretstore/store.go index 5d8d2a9..d88d3d2 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -31,14 +31,15 @@ const ( ) type Options struct { - ServiceName string - BackendPolicy BackendPolicy - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string - BitwardenCommand string - BitwardenDebug bool - Shell string + ServiceName string + BackendPolicy BackendPolicy + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + BitwardenDebug bool + DisableBitwardenCache bool + Shell string } type Store interface { -- 2.45.2 From 85da274772dc27d2e973f5561d77243aaa5082d2 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:00:57 +0200 Subject: [PATCH 49/79] feat: add encrypted bitwarden cache core --- secretstore/bitwarden_cache.go | 376 ++++++++++++++++++++++++++++ secretstore/bitwarden_cache_test.go | 131 ++++++++++ 2 files changed, 507 insertions(+) create mode 100644 secretstore/bitwarden_cache.go create mode 100644 secretstore/bitwarden_cache_test.go diff --git a/secretstore/bitwarden_cache.go b/secretstore/bitwarden_cache.go new file mode 100644 index 0000000..a2a9e55 --- /dev/null +++ b/secretstore/bitwarden_cache.go @@ -0,0 +1,376 @@ +package secretstore + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + bitwardenCacheEnvName = "MCP_FRAMEWORK_BITWARDEN_CACHE" + defaultBitwardenCacheTTL = 10 * time.Minute + bitwardenCacheFormatVersion = 1 + bitwardenCacheAlgorithm = "AES-256-GCM" + bitwardenCacheDirName = "bitwarden-cache" + bitwardenCacheSalt = "mcp-framework bitwarden cache salt v1" + bitwardenCacheInfo = "mcp-framework bitwarden cache v1" + bitwardenCacheEncryptionInfo = "mcp-framework bitwarden cache encryption v1" + bitwardenCacheEntryIDInfo = "mcp-framework bitwarden cache entry id v1" + bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1" +) + +type bitwardenCacheOptions struct { + ServiceName string + Session string + TTL time.Duration + Now func() time.Time + CacheDir string + Enabled bool +} + +type bitwardenCache struct { + mu sync.Mutex + enabled bool + serviceName string + ttl time.Duration + now func() time.Time + cacheDir string + encryptionKey []byte + entryIDKey []byte + memory map[string]bitwardenCacheMemoryEntry +} + +type bitwardenCacheMemoryEntry struct { + value string + expiresAt time.Time +} + +type bitwardenCachePlaintext struct { + Version int `json:"version"` + ServiceName string `json:"service_name"` + SecretName string `json:"secret_name"` + ScopedName string `json:"scoped_name"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Value string `json:"value"` +} + +type bitwardenCacheEnvelope struct { + Version int `json:"version"` + Algorithm string `json:"algorithm"` + Nonce string `json:"nonce"` + Ciphertext string `json:"ciphertext"` +} + +func newBitwardenCache(options bitwardenCacheOptions) *bitwardenCache { + now := options.Now + if now == nil { + now = time.Now + } + ttl := options.TTL + if ttl <= 0 { + ttl = defaultBitwardenCacheTTL + } + + cache := &bitwardenCache{ + enabled: options.Enabled, + serviceName: strings.TrimSpace(options.ServiceName), + ttl: ttl, + now: now, + cacheDir: strings.TrimSpace(options.CacheDir), + memory: map[string]bitwardenCacheMemoryEntry{}, + } + if !cache.enabled { + return cache + } + + session := strings.TrimSpace(options.Session) + if session == "" { + return cache + } + masterKey, err := hkdf.Key(sha256.New, []byte(session), []byte(bitwardenCacheSalt), bitwardenCacheInfo, 32) + if err != nil { + return cache + } + cache.encryptionKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEncryptionInfo, 32) + if err != nil { + cache.encryptionKey = nil + return cache + } + cache.entryIDKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEntryIDInfo, 32) + if err != nil { + cache.encryptionKey = nil + cache.entryIDKey = nil + return cache + } + return cache +} + +func (c *bitwardenCache) load(secretName, scopedName string) (string, bool) { + if value, ok := c.loadMemory(secretName, scopedName); ok { + return value, true + } + return c.loadDisk(secretName, scopedName) +} + +func (c *bitwardenCache) store(secretName, scopedName, value string) { + if c == nil || !c.enabled { + return + } + c.storeMemory(secretName, scopedName, value) + c.storeDisk(secretName, scopedName, value) +} + +func (c *bitwardenCache) invalidate(secretName, scopedName string) { + if c == nil { + return + } + key := c.memoryKey(secretName, scopedName) + c.mu.Lock() + delete(c.memory, key) + c.mu.Unlock() + if path, ok := c.entryPath(secretName, scopedName); ok { + _ = os.Remove(path) + } +} + +func (c *bitwardenCache) loadMemory(secretName, scopedName string) (string, bool) { + if c == nil || !c.enabled { + return "", false + } + key := c.memoryKey(secretName, scopedName) + now := c.now() + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.memory[key] + if !ok { + return "", false + } + if !entry.expiresAt.After(now) { + delete(c.memory, key) + return "", false + } + return entry.value, true +} + +func (c *bitwardenCache) storeMemory(secretName, scopedName, value string) { + key := c.memoryKey(secretName, scopedName) + c.mu.Lock() + c.memory[key] = bitwardenCacheMemoryEntry{ + value: value, + expiresAt: c.now().Add(c.ttl), + } + c.mu.Unlock() +} + +func (c *bitwardenCache) loadDisk(secretName, scopedName string) (string, bool) { + path, ok := c.entryPath(secretName, scopedName) + if !ok { + return "", false + } + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + var envelope bitwardenCacheEnvelope + if err := json.Unmarshal(data, &envelope); err != nil { + _ = os.Remove(path) + return "", false + } + plaintext, err := c.decryptEnvelope(secretName, scopedName, envelope) + if err != nil { + _ = os.Remove(path) + return "", false + } + if plaintext.Version != bitwardenCacheFormatVersion || + plaintext.ServiceName != c.serviceName || + plaintext.SecretName != strings.TrimSpace(secretName) || + plaintext.ScopedName != strings.TrimSpace(scopedName) || + !plaintext.ExpiresAt.After(c.now()) { + _ = os.Remove(path) + return "", false + } + c.storeMemory(secretName, scopedName, plaintext.Value) + return plaintext.Value, true +} + +func (c *bitwardenCache) storeDisk(secretName, scopedName, value string) { + if c.cacheDir == "" || len(c.encryptionKey) == 0 || len(c.entryIDKey) == 0 { + return + } + path, ok := c.entryPath(secretName, scopedName) + if !ok { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return + } + _ = os.Chmod(filepath.Dir(path), 0o700) + + now := c.now() + plaintext := bitwardenCachePlaintext{ + Version: bitwardenCacheFormatVersion, + ServiceName: c.serviceName, + SecretName: strings.TrimSpace(secretName), + ScopedName: strings.TrimSpace(scopedName), + CreatedAt: now, + ExpiresAt: now.Add(c.ttl), + Value: value, + } + envelope, err := c.encryptPlaintext(secretName, scopedName, plaintext) + if err != nil { + return + } + data, err := json.Marshal(envelope) + if err != nil { + return + } + tmp, err := os.CreateTemp(filepath.Dir(path), "bitwarden-cache-*.tmp") + if err != nil { + return + } + tmpPath := tmp.Name() + cleanup := true + defer func() { + _ = tmp.Close() + if cleanup { + _ = os.Remove(tmpPath) + } + }() + _ = tmp.Chmod(0o600) + if _, err := tmp.Write(data); err != nil { + return + } + if err := tmp.Close(); err != nil { + return + } + if err := os.Rename(tmpPath, path); err != nil { + return + } + _ = os.Chmod(path, 0o600) + cleanup = false +} + +func (c *bitwardenCache) encryptPlaintext(secretName, scopedName string, plaintext bitwardenCachePlaintext) (bitwardenCacheEnvelope, error) { + raw, err := json.Marshal(plaintext) + if err != nil { + return bitwardenCacheEnvelope{}, err + } + block, err := aes.NewCipher(c.encryptionKey) + if err != nil { + return bitwardenCacheEnvelope{}, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return bitwardenCacheEnvelope{}, err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return bitwardenCacheEnvelope{}, err + } + ciphertext := aead.Seal(nil, nonce, raw, c.additionalData(secretName, scopedName)) + return bitwardenCacheEnvelope{ + Version: bitwardenCacheFormatVersion, + Algorithm: bitwardenCacheAlgorithm, + Nonce: base64.StdEncoding.EncodeToString(nonce), + Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), + }, nil +} + +func (c *bitwardenCache) decryptEnvelope(secretName, scopedName string, envelope bitwardenCacheEnvelope) (bitwardenCachePlaintext, error) { + if envelope.Version != bitwardenCacheFormatVersion || envelope.Algorithm != bitwardenCacheAlgorithm { + return bitwardenCachePlaintext{}, errors.New("unsupported bitwarden cache envelope") + } + nonce, err := base64.StdEncoding.DecodeString(envelope.Nonce) + if err != nil { + return bitwardenCachePlaintext{}, err + } + ciphertext, err := base64.StdEncoding.DecodeString(envelope.Ciphertext) + if err != nil { + return bitwardenCachePlaintext{}, err + } + block, err := aes.NewCipher(c.encryptionKey) + if err != nil { + return bitwardenCachePlaintext{}, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return bitwardenCachePlaintext{}, err + } + raw, err := aead.Open(nil, nonce, ciphertext, c.additionalData(secretName, scopedName)) + if err != nil { + return bitwardenCachePlaintext{}, err + } + var plaintext bitwardenCachePlaintext + if err := json.Unmarshal(raw, &plaintext); err != nil { + return bitwardenCachePlaintext{}, err + } + return plaintext, nil +} + +func (c *bitwardenCache) entryPath(secretName, scopedName string) (string, bool) { + if c == nil || !c.enabled || c.cacheDir == "" || len(c.entryIDKey) == 0 { + return "", false + } + mac := hmac.New(sha256.New, c.entryIDKey) + _, _ = mac.Write([]byte(c.cacheContext(secretName, scopedName))) + return filepath.Join(c.cacheDir, hex.EncodeToString(mac.Sum(nil))+".json"), true +} + +func (c *bitwardenCache) memoryKey(secretName, scopedName string) string { + return c.cacheContext(secretName, scopedName) +} + +func (c *bitwardenCache) additionalData(secretName, scopedName string) []byte { + return []byte(fmt.Sprintf( + "mcp-framework bitwarden cache v1\nservice=%s\nsecret=%s\nscoped=%s", + c.serviceName, + strings.TrimSpace(secretName), + strings.TrimSpace(scopedName), + )) +} + +func (c *bitwardenCache) cacheContext(secretName, scopedName string) string { + return fmt.Sprintf( + "version=%d\nservice=%s\nsecret=%s\nscoped=%s\nscope=%s", + bitwardenCacheFormatVersion, + c.serviceName, + strings.TrimSpace(secretName), + strings.TrimSpace(scopedName), + bitwardenCacheContextScope, + ) +} + +func resolveBitwardenCacheDir(serviceName string) string { + cacheRoot, err := os.UserCacheDir() + if err != nil { + return "" + } + return filepath.Join(cacheRoot, strings.TrimSpace(serviceName), bitwardenCacheDirName) +} + +func bitwardenCacheDisabledByEnv() bool { + raw, ok := os.LookupEnv(bitwardenCacheEnvName) + if !ok { + return false + } + switch strings.ToLower(strings.TrimSpace(raw)) { + case "0", "false", "no", "off", "disabled": + return true + default: + return false + } +} diff --git a/secretstore/bitwarden_cache_test.go b/secretstore/bitwarden_cache_test.go new file mode 100644 index 0000000..5901229 --- /dev/null +++ b/secretstore/bitwarden_cache_test.go @@ -0,0 +1,131 @@ +package secretstore + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestBitwardenCacheMemoryHit(t *testing.T) { + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: 10 * time.Minute, + Now: func() time.Time { return time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) }, + CacheDir: t.TempDir(), + Enabled: true, + }) + + cache.store("api-token", "email-mcp/api-token", "secret-v1") + got, ok := cache.loadMemory("api-token", "email-mcp/api-token") + if !ok { + t.Fatal("memory cache miss, want hit") + } + if got != "secret-v1" { + t.Fatalf("memory cache value = %q, want secret-v1", got) + } +} + +func TestBitwardenCacheDiskRoundTripIsEncrypted(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: 10 * time.Minute, + Now: func() time.Time { return now }, + CacheDir: dir, + Enabled: true, + }) + + cache.store("api-token", "email-mcp/api-token", "secret-v1") + + reopened := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: 10 * time.Minute, + Now: func() time.Time { return now.Add(time.Minute) }, + CacheDir: dir, + Enabled: true, + }) + got, ok := reopened.loadDisk("api-token", "email-mcp/api-token") + if !ok { + t.Fatal("disk cache miss, want hit") + } + if got != "secret-v1" { + t.Fatalf("disk cache value = %q, want secret-v1", got) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir cache dir: %v", err) + } + if len(entries) != 1 { + t.Fatalf("cache file count = %d, want 1", len(entries)) + } + data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) + if err != nil { + t.Fatalf("ReadFile cache file: %v", err) + } + if bytes.Contains(data, []byte("secret-v1")) { + t.Fatalf("cache file contains plaintext secret: %s", data) + } + if strings.Contains(entries[0].Name(), "api-token") { + t.Fatalf("cache file name exposes secret name: %s", entries[0].Name()) + } +} + +func TestBitwardenCacheRejectsChangedSession(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: 10 * time.Minute, + Now: func() time.Time { return now }, + CacheDir: dir, + Enabled: true, + }) + cache.store("api-token", "email-mcp/api-token", "secret-v1") + + changed := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v2", + TTL: 10 * time.Minute, + Now: func() time.Time { return now.Add(time.Minute) }, + CacheDir: dir, + Enabled: true, + }) + if got, ok := changed.loadDisk("api-token", "email-mcp/api-token"); ok { + t.Fatalf("disk cache hit with changed session = %q, want miss", got) + } +} + +func TestBitwardenCacheExpiresEntries(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: time.Minute, + Now: func() time.Time { return now }, + CacheDir: dir, + Enabled: true, + }) + cache.store("api-token", "email-mcp/api-token", "secret-v1") + + expired := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: "session-v1", + TTL: time.Minute, + Now: func() time.Time { return now.Add(2 * time.Minute) }, + CacheDir: dir, + Enabled: true, + }) + if got, ok := expired.load("api-token", "email-mcp/api-token"); ok { + t.Fatalf("expired cache hit = %q, want miss", got) + } +} -- 2.45.2 From fd08615950d2fa2e080763a31b8183ca60f6b3b0 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:03:35 +0200 Subject: [PATCH 50/79] feat: cache bitwarden secret reads --- secretstore/bitwarden.go | 45 +++++++++-- secretstore/bitwarden_test.go | 121 +++++++++++++++++++++++++++++- secretstore/manifest_open_test.go | 45 +++++++++++ 3 files changed, 204 insertions(+), 7 deletions(-) diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 362c019..3de1960 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -50,6 +50,7 @@ type bitwardenStore struct { command string serviceName string debug bool + cache *bitwardenCache } type bitwardenListItem struct { @@ -68,12 +69,6 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string } debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) - store := &bitwardenStore{ - command: command, - serviceName: serviceName, - debug: debugEnabled, - } - if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ ServiceName: serviceName, }); err != nil { @@ -85,6 +80,21 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string ) } + session, _ := os.LookupEnv(bitwardenSessionEnvName) + cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv() + store := &bitwardenStore{ + command: command, + serviceName: serviceName, + debug: debugEnabled, + cache: newBitwardenCache(bitwardenCacheOptions{ + ServiceName: serviceName, + Session: session, + TTL: defaultBitwardenCacheTTL, + CacheDir: resolveBitwardenCacheDir(serviceName), + Enabled: cacheEnabled, + }), + } + if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { if errors.Is(err, exec.ErrNotFound) { return nil, fmt.Errorf( @@ -265,6 +275,10 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { ); err != nil { return err } + if s.cache != nil { + s.cache.invalidate(name, secretName) + s.cache.store(name, secretName, secret) + } return nil case err != nil: return err @@ -287,11 +301,21 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error { return err } + if s.cache != nil { + s.cache.invalidate(name, secretName) + s.cache.store(name, secretName, secret) + } return nil } func (s *bitwardenStore) GetSecret(name string) (string, error) { secretName := s.scopedName(name) + if s.cache != nil { + if secret, ok := s.cache.load(name, secretName); ok { + return secret, nil + } + } + _, payload, err := s.findItem(secretName, name) if err != nil { return "", err @@ -302,6 +326,9 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) { return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName) } + if s.cache != nil { + s.cache.store(name, secretName, secret) + } return secret, nil } @@ -309,6 +336,9 @@ func (s *bitwardenStore) DeleteSecret(name string) error { secretName := s.scopedName(name) item, _, err := s.findItem(secretName, name) if errors.Is(err, ErrNotFound) { + if s.cache != nil { + s.cache.invalidate(name, secretName) + } return nil } if err != nil { @@ -325,6 +355,9 @@ func (s *bitwardenStore) DeleteSecret(name string) error { return err } + if s.cache != nil { + s.cache.invalidate(name, secretName) + } return nil } diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 4b64c57..1860bc5 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -370,6 +370,124 @@ func TestBitwardenStoreGetSecretReadsSelectedItemOnlyOnce(t *testing.T) { } } +func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + for i := 0; i < 2; i++ { + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret #%d returned error: %v", i+1, err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) + } + } + if fakeCLI.getItemCalls != 1 { + t.Fatalf("bw get item count = %d, want 1 with memory cache", fakeCLI.getItemCalls) + } +} + +func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) { + withBitwardenSession(t) + t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0") + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + for i := 0; i < 2; i++ { + if _, err := store.GetSecret("api-token"); err != nil { + t.Fatalf("GetSecret #%d returned error: %v", i+1, err) + } + } + if fakeCLI.getItemCalls != 2 { + t.Fatalf("bw get item count = %d, want 2 when env disables cache", fakeCLI.getItemCalls) + } +} + +func TestBitwardenStoreSetSecretRefreshesCache(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { + t.Fatalf("SetSecret v1 returned error: %v", err) + } + if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v1" { + t.Fatalf("GetSecret after v1 = %q, %v; want secret-v1, nil", got, err) + } + if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil { + t.Fatalf("SetSecret v2 returned error: %v", err) + } + if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v2" { + t.Fatalf("GetSecret after v2 = %q, %v; want secret-v2, nil", got, err) + } +} + +func TestBitwardenStoreDeleteSecretInvalidatesCache(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + if _, err := store.GetSecret("api-token"); err != nil { + t.Fatalf("GetSecret before delete returned error: %v", err) + } + if err := store.DeleteSecret("api-token"); err != nil { + t.Fatalf("DeleteSecret returned error: %v", err) + } + _, err = store.GetSecret("api-token") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err) + } +} + func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) { store := &bitwardenStore{command: "bw", serviceName: "email-mcp"} withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { @@ -549,7 +667,8 @@ func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { func withBitwardenSession(t *testing.T) { t.Helper() - t.Setenv("BW_SESSION", "test-session") + sessionName := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name()) + t.Setenv("BW_SESSION", "test-session-"+sessionName) } func withBitwardenRunner( diff --git a/secretstore/manifest_open_test.go b/secretstore/manifest_open_test.go index 0a92d37..c60e8c6 100644 --- a/secretstore/manifest_open_test.go +++ b/secretstore/manifest_open_test.go @@ -138,6 +138,51 @@ func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) { } } +func TestOpenFromManifestAppliesBitwardenCacheDisable(t *testing.T) { + withBitwardenSession(t) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + cacheDisabled := false + store, err := OpenFromManifest(OpenFromManifestOptions{ + ServiceName: "email-mcp", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{ + BackendPolicy: string(BackendBitwardenCLI), + BitwardenCache: &cacheDisabled, + }, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("OpenFromManifest returned error: %v", err) + } + + for i := 0; i < 2; i++ { + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret #%d returned error: %v", i+1, err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value) + } + } + if fakeCLI.getItemCalls != 2 { + t.Fatalf("bw get item count = %d, want 2 when manifest disables cache", fakeCLI.getItemCalls) + } +} + func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) { execErr := errors.New("boom") _, err := OpenFromManifest(OpenFromManifestOptions{ -- 2.45.2 From 5552e639748a9ebb3ab9680be2885d9a8ba5f701 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:04:29 +0200 Subject: [PATCH 51/79] feat: expose bitwarden cache in generated helpers --- generate/generate.go | 19 +++++++++++-------- generate/generate_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/generate/generate.go b/generate/generate.go index e5d3669..6128964 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -342,14 +342,15 @@ import ( ) type SecretStoreOptions struct { - ServiceName string - LookupEnv func(string) (string, bool) - KWalletAppID string - KWalletFolder string - BitwardenCommand string - BitwardenDebug bool - Shell string - ExecutableResolver fwsecretstore.ExecutableResolver + ServiceName string + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + BitwardenDebug bool + DisableBitwardenCache bool + Shell string + ExecutableResolver fwsecretstore.ExecutableResolver } func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) { @@ -372,6 +373,7 @@ func secretStoreOpenOptions(options SecretStoreOptions) fwsecretstore.OpenFromMa KWalletFolder: options.KWalletFolder, BitwardenCommand: options.BitwardenCommand, BitwardenDebug: options.BitwardenDebug, + DisableBitwardenCache: options.DisableBitwardenCache, Shell: options.Shell, ManifestLoader: LoadManifest, ExecutableResolver: options.ExecutableResolver, @@ -386,6 +388,7 @@ func secretStoreDescribeOptions(options SecretStoreOptions) fwsecretstore.Descri KWalletFolder: options.KWalletFolder, BitwardenCommand: options.BitwardenCommand, BitwardenDebug: options.BitwardenDebug, + DisableBitwardenCache: options.DisableBitwardenCache, Shell: options.Shell, ManifestLoader: LoadManifest, ExecutableResolver: options.ExecutableResolver, diff --git a/generate/generate_test.go b/generate/generate_test.go index 1420809..e60de93 100644 --- a/generate/generate_test.go +++ b/generate/generate_test.go @@ -322,6 +322,33 @@ sources = ["flag", "env", "secret"] } } +func TestGenerateSecretStoreIncludesBitwardenCacheOption(t *testing.T) { + projectDir := newProject(t, ` +binary_name = "demo-mcp" + +[secret_store] +backend_policy = "bitwarden-cli" +`) + + if _, err := Generate(Options{ProjectDir: projectDir}); err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go")) + if err != nil { + t.Fatalf("ReadFile generated secretstore: %v", err) + } + text := string(content) + for _, snippet := range []string{ + "DisableBitwardenCache bool", + "DisableBitwardenCache: options.DisableBitwardenCache,", + } { + if !strings.Contains(text, snippet) { + t.Fatalf("generated secretstore.go missing %q:\n%s", snippet, text) + } + } +} + func newProject(t *testing.T, manifest string) string { t.Helper() -- 2.45.2 From 0135b093a56c723ef72ac92a2450ccbdd181a09d Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:04:54 +0200 Subject: [PATCH 52/79] docs: document bitwarden cache controls --- docs/manifest.md | 3 +++ docs/secrets.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/docs/manifest.md b/docs/manifest.md index 0f945c5..8311969 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -29,6 +29,8 @@ known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"] [secret_store] backend_policy = "auto" +# Optionnel : mettre false pour désactiver le cache Bitwarden. +bitwarden_cache = true [profiles] default = "prod" @@ -80,6 +82,7 @@ Champs supportés : - `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`, `bitwarden-cli`). +- `[secret_store].bitwarden_cache` : active le cache Bitwarden mémoire et disque chiffré quand `backend_policy = "bitwarden-cli"`. Par défaut, le cache est activé si le champ est absent. Mettre `false` pour le désactiver. - `[profiles].default` : profil recommandé par défaut. - `[profiles].known` : profils connus du projet. - `[bootstrap].description` : description CLI utilisée par le bootstrap. diff --git a/docs/secrets.md b/docs/secrets.md index b3f97a7..3c011ae 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -72,6 +72,34 @@ store, err := secretstore.Open(secretstore.Options{ }) ``` +## Cache Bitwarden + +Le backend `bitwarden-cli` met en cache les lectures de secrets par défaut. +Le cache mémoire évite les appels répétés au CLI dans un même process. Le cache +disque est chiffré avec une clé dérivée de `BW_SESSION` via HKDF-SHA256 et +AES-GCM. + +TTL par défaut : 10 minutes. + +Pour désactiver le cache dans `mcp.toml` : + +```toml +[secret_store] +backend_policy = "bitwarden-cli" +bitwarden_cache = false +``` + +Pour le désactiver sans modifier le manifeste : + +```bash +MCP_FRAMEWORK_BITWARDEN_CACHE=0 +``` + +Le fichier de cache et le binaire installé ne suffisent pas à déchiffrer les +secrets. Si `BW_SESSION` change ou disparaît, les entrées disque existantes +deviennent inutilisables. Cette protection ne couvre pas un attaquant qui peut +lire l'environnement ou la mémoire du process pendant l'exécution. + Pour vérifier explicitement que Bitwarden est prêt (login + unlock + `BW_SESSION`) : ```go -- 2.45.2 From 893600ffd52173b949e25da810658d46edd92b0d Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:30:18 +0200 Subject: [PATCH 53/79] perf: lazy check bitwarden readiness --- secretstore/bitwarden.go | 63 ++++++++++---- secretstore/bitwarden_cache.go | 4 +- secretstore/bitwarden_cache_test.go | 10 +++ secretstore/bitwarden_test.go | 123 +++++++++++++++++++++++++--- secretstore/runtime.go | 11 +++ 5 files changed, 180 insertions(+), 31 deletions(-) diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 3de1960..9c580b8 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -50,6 +50,8 @@ type bitwardenStore struct { command string serviceName string debug bool + lookupEnv func(string) (string, bool) + shell string cache *bitwardenCache } @@ -86,6 +88,8 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string command: command, serviceName: serviceName, debug: debugEnabled, + lookupEnv: options.LookupEnv, + shell: options.Shell, cache: newBitwardenCache(bitwardenCacheOptions{ ServiceName: serviceName, Session: session, @@ -95,39 +99,41 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string }), } - if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { + return store, nil +} + +func verifyBitwardenCLIReady(options Options) error { + command := strings.TrimSpace(options.BitwardenCommand) + if command == "" { + command = defaultBitwardenCommand + } + debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) + + if _, err := runBitwardenCommand(command, debugEnabled, nil, "--version"); err != nil { if errors.Is(err, exec.ErrNotFound) { - return nil, fmt.Errorf( - "secret backend policy %q requires bitwarden CLI command %q in PATH: %w", - policy, + return fmt.Errorf( + "requires bitwarden CLI command %q in PATH: %w", command, - ErrBackendUnavailable, + errors.Join(ErrBackendUnavailable, err), ) } - return nil, fmt.Errorf( - "secret backend policy %q cannot verify bitwarden CLI command %q: %w", - policy, + return fmt.Errorf( + "cannot verify bitwarden CLI command %q: %w", command, errors.Join(ErrBackendUnavailable, err), ) } - if err := EnsureBitwardenReady(Options{ - BitwardenCommand: command, - BitwardenDebug: debugEnabled, - LookupEnv: options.LookupEnv, - Shell: options.Shell, - }); err != nil { - return nil, fmt.Errorf( - "secret backend policy %q cannot use bitwarden CLI command %q right now: %w", - policy, + if err := EnsureBitwardenReady(options); err != nil { + return fmt.Errorf( + "cannot use bitwarden CLI command %q right now: %w", command, errors.Join(ErrBackendUnavailable, err), ) } - return store, nil + return nil } func EnsureBitwardenReady(options Options) error { @@ -252,6 +258,10 @@ func detectShellFlavor(shellHint string) string { func (s *bitwardenStore) SetSecret(name, label, secret string) error { secretName := s.scopedName(name) + if err := s.ensureReady(); err != nil { + return fmt.Errorf("prepare bitwarden CLI for saving secret %q: %w", name, err) + } + item, payload, err := s.findItem(secretName, name) switch { case errors.Is(err, ErrNotFound): @@ -316,6 +326,10 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) { } } + if err := s.ensureReady(); err != nil { + return "", fmt.Errorf("prepare bitwarden CLI for reading secret %q: %w", name, err) + } + _, payload, err := s.findItem(secretName, name) if err != nil { return "", err @@ -334,6 +348,10 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) { func (s *bitwardenStore) DeleteSecret(name string) error { secretName := s.scopedName(name) + if err := s.ensureReady(); err != nil { + return fmt.Errorf("prepare bitwarden CLI for deleting secret %q: %w", name, err) + } + item, _, err := s.findItem(secretName, name) if errors.Is(err, ErrNotFound) { if s.cache != nil { @@ -365,6 +383,15 @@ func (s *bitwardenStore) scopedName(name string) string { return fmt.Sprintf("%s/%s", s.serviceName, name) } +func (s *bitwardenStore) ensureReady() error { + return verifyBitwardenCLIReady(Options{ + BitwardenCommand: s.command, + BitwardenDebug: s.debug, + LookupEnv: s.lookupEnv, + Shell: s.shell, + }) +} + type bitwardenResolvedItem struct { item bitwardenListItem payload map[string]any diff --git a/secretstore/bitwarden_cache.go b/secretstore/bitwarden_cache.go index a2a9e55..7990e8b 100644 --- a/secretstore/bitwarden_cache.go +++ b/secretstore/bitwarden_cache.go @@ -32,6 +32,8 @@ const ( bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1" ) +var bitwardenUserCacheDir = os.UserCacheDir + type bitwardenCacheOptions struct { ServiceName string Session string @@ -355,7 +357,7 @@ func (c *bitwardenCache) cacheContext(secretName, scopedName string) string { } func resolveBitwardenCacheDir(serviceName string) string { - cacheRoot, err := os.UserCacheDir() + cacheRoot, err := bitwardenUserCacheDir() if err != nil { return "" } diff --git a/secretstore/bitwarden_cache_test.go b/secretstore/bitwarden_cache_test.go index 5901229..d26097d 100644 --- a/secretstore/bitwarden_cache_test.go +++ b/secretstore/bitwarden_cache_test.go @@ -129,3 +129,13 @@ func TestBitwardenCacheExpiresEntries(t *testing.T) { t.Fatalf("expired cache hit = %q, want miss", got) } } + +func withBitwardenUserCacheDir(t *testing.T, resolver func() (string, error)) { + t.Helper() + + previous := bitwardenUserCacheDir + bitwardenUserCacheDir = resolver + t.Cleanup(func() { + bitwardenUserCacheDir = previous + }) +} diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 1860bc5..cafdfc3 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -34,23 +34,29 @@ func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) { if _, ok := store.(*bitwardenStore); !ok { t.Fatalf("store type = %T, want *bitwardenStore", store) } - if !fakeCLI.versionChecked { - t.Fatal("expected bitwarden CLI version check") + if fakeCLI.versionChecked { + t.Fatal("Open should not check bitwarden CLI version before a cache miss or write") } - if !fakeCLI.statusChecked { - t.Fatal("expected bitwarden CLI status check") + if fakeCLI.statusChecked { + t.Fatal("Open should not check bitwarden CLI status before a cache miss or write") } } -func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) { +func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T) { + withBitwardenSession(t) withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { return nil, &exec.Error{Name: command, Err: exec.ErrNotFound} }) - _, err := Open(Options{ + store, err := Open(Options{ ServiceName: "email-mcp", BackendPolicy: BackendBitwardenCLI, }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + _, err = store.GetSecret("api-token") if err == nil { t.Fatal("expected error") } @@ -59,26 +65,28 @@ func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) { } } -func TestOpenBitwardenCLIFailsWhenSessionIsMissing(t *testing.T) { +func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) { fakeCLI := newFakeBitwardenCLI("bw") withBitwardenRunner(t, fakeCLI.run) - _, err := Open(Options{ + store, err := Open(Options{ ServiceName: "email-mcp", BackendPolicy: BackendBitwardenCLI, LookupEnv: func(name string) (string, bool) { return "", false }, }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + _, err = store.GetSecret("api-token") if err == nil { t.Fatal("expected error") } if !errors.Is(err, ErrBWLocked) { t.Fatalf("error = %v, want ErrBWLocked", err) } - if !errors.Is(err, ErrBackendUnavailable) { - t.Fatalf("error = %v, want ErrBackendUnavailable", err) - } } func TestEnsureBitwardenReadyGuidesLoginAndUnlock(t *testing.T) { @@ -404,6 +412,81 @@ func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) { } } +func TestBitwardenStoreDiskCacheHitSkipsBitwardenCLI(t *testing.T) { + withBitwardenSession(t) + cacheRoot := t.TempDir() + withBitwardenUserCacheDir(t, func() (string, error) { + return cacheRoot, nil + }) + + cache := newBitwardenCache(bitwardenCacheOptions{ + ServiceName: "email-mcp", + Session: os.Getenv("BW_SESSION"), + TTL: defaultBitwardenCacheTTL, + CacheDir: resolveBitwardenCacheDir("email-mcp"), + Enabled: true, + }) + cache.store("api-token", "email-mcp/api-token", "secret-from-cache") + + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + return nil, fmt.Errorf("unexpected bitwarden invocation: %v", args) + }) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "secret-from-cache" { + t.Fatalf("GetSecret = %q, want secret-from-cache", value) + } +} + +func TestBitwardenStoreCacheMissChecksReadinessLazily(t *testing.T) { + withBitwardenSession(t) + withBitwardenUserCacheDir(t, func() (string, error) { + return t.TempDir(), nil + }) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + if fakeCLI.statusChecked { + t.Fatal("Open should not check bitwarden status") + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret = %q, want secret-v1", value) + } + if !fakeCLI.statusChecked { + t.Fatal("cache miss should check bitwarden status before CLI lookup") + } +} + func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) { withBitwardenSession(t) t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0") @@ -522,20 +605,33 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) { func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) { withBitwardenSession(t) + withBitwardenUserCacheDir(t, func() (string, error) { + return t.TempDir(), nil + }) fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } withBitwardenRunner(t, fakeCLI.run) t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1") var logs bytes.Buffer withBitwardenDebugOutput(t, &logs) - _, err := Open(Options{ + store, err := Open(Options{ ServiceName: "email-mcp", BackendPolicy: BackendBitwardenCLI, }) if err != nil { t.Fatalf("Open returned error: %v", err) } + if _, err := store.GetSecret("api-token"); err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } text := logs.String() if !strings.Contains(text, "bw --version") { @@ -669,6 +765,9 @@ func withBitwardenSession(t *testing.T) { t.Helper() sessionName := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name()) t.Setenv("BW_SESSION", "test-session-"+sessionName) + withBitwardenUserCacheDir(t, func() (string, error) { + return t.TempDir(), nil + }) } func withBitwardenRunner( diff --git a/secretstore/runtime.go b/secretstore/runtime.go index f0894a9..7604b99 100644 --- a/secretstore/runtime.go +++ b/secretstore/runtime.go @@ -91,6 +91,17 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) desc.EffectivePolicy = effective desc.DisplayName = BackendDisplayName(effective) } + if desc.EffectivePolicy == BackendBitwardenCLI { + if err := verifyBitwardenCLIReady(Options{ + BitwardenCommand: options.BitwardenCommand, + BitwardenDebug: options.BitwardenDebug, + LookupEnv: options.LookupEnv, + Shell: options.Shell, + }); err != nil { + desc.Ready = false + desc.ReadyError = err + } + } return desc, nil } -- 2.45.2 From 1e11181c02554d04cad0e134bb4705d1e27a3954 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 15:47:07 +0200 Subject: [PATCH 54/79] perf: avoid bitwarden probe in runtime description --- docs/secrets.md | 6 ++++-- secretstore/runtime.go | 4 +++- secretstore/runtime_test.go | 22 ++++++---------------- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/docs/secrets.md b/docs/secrets.md index 3c011ae..724419b 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -187,7 +187,8 @@ effective := secretstore.EffectiveBackendPolicy(store) fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any... ``` -Pour obtenir en un seul appel une description runtime (source manifeste, policy déclarée/effective, disponibilité) : +Pour obtenir en un seul appel une description runtime légère (source manifeste, +policy déclarée/effective, backend affiché) : ```go desc, err := secretstore.DescribeRuntime(secretstore.DescribeRuntimeOptions{ @@ -202,7 +203,8 @@ fmt.Println(secretstore.FormatBackendStatus(desc)) // declared=... effective=... display=... ready=... source=... ``` -Pour un préflight réutilisable dans `setup`, `config show` et `config test` : +`DescribeRuntime` ne contacte pas Bitwarden par défaut. Pour vérifier réellement +la disponibilité du backend, utiliser le préflight : ```go report, err := secretstore.PreflightFromManifest(secretstore.PreflightOptions{ diff --git a/secretstore/runtime.go b/secretstore/runtime.go index 7604b99..08b93ab 100644 --- a/secretstore/runtime.go +++ b/secretstore/runtime.go @@ -16,6 +16,7 @@ type DescribeRuntimeOptions struct { BitwardenCommand string BitwardenDebug bool DisableBitwardenCache bool + CheckReady bool Shell string ManifestLoader ManifestLoader ExecutableResolver ExecutableResolver @@ -91,7 +92,7 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) desc.EffectivePolicy = effective desc.DisplayName = BackendDisplayName(effective) } - if desc.EffectivePolicy == BackendBitwardenCLI { + if options.CheckReady && desc.EffectivePolicy == BackendBitwardenCLI { if err := verifyBitwardenCLIReady(Options{ BitwardenCommand: options.BitwardenCommand, BitwardenDebug: options.BitwardenDebug, @@ -107,6 +108,7 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) } func PreflightFromManifest(options PreflightOptions) (PreflightReport, error) { + options.CheckReady = true desc, err := DescribeRuntime(options) if err != nil { return PreflightReport{}, err diff --git a/secretstore/runtime_test.go b/secretstore/runtime_test.go index 3daf05b..23f90f1 100644 --- a/secretstore/runtime_test.go +++ b/secretstore/runtime_test.go @@ -46,16 +46,9 @@ func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) { } } -func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(t *testing.T) { +func TestDescribeRuntimeDoesNotProbeBitwardenByDefault(t *testing.T) { withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { - switch { - case len(args) == 1 && args[0] == "--version": - return []byte("2026.1.0\n"), nil - case len(args) == 1 && args[0] == "status": - return []byte(`{"status":"locked"}`), nil - default: - return nil, errors.New("unexpected bitwarden invocation") - } + return nil, errors.New("unexpected bitwarden invocation") }) desc, err := DescribeRuntime(DescribeRuntimeOptions{ @@ -80,14 +73,11 @@ func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(t *testing.T) { if desc.EffectivePolicy != BackendBitwardenCLI { t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendBitwardenCLI) } - if desc.Ready { - t.Fatalf("Ready = %v, want false", desc.Ready) + if !desc.Ready { + t.Fatalf("Ready = %v, want true without readiness probe", desc.Ready) } - if !errors.Is(desc.ReadyError, ErrBWLocked) { - t.Fatalf("ReadyError = %v, want ErrBWLocked", desc.ReadyError) - } - if !strings.Contains(desc.ReadyError.Error(), "set -x BW_SESSION (bw unlock --raw)") { - t.Fatalf("ReadyError = %v, want fish remediation", desc.ReadyError) + if desc.ReadyError != nil { + t.Fatalf("ReadyError = %v, want nil without readiness probe", desc.ReadyError) } } -- 2.45.2 From 6e85969cf43a9a945e1bf95097cfcbfa6f72a413 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 5 May 2026 12:13:01 +0200 Subject: [PATCH 55/79] migrate workflows to forgejo --- {.gitea => .jorgejo}/workflows/ci.yml | 0 {.gitea => .jorgejo}/workflows/release.yml | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {.gitea => .jorgejo}/workflows/ci.yml (100%) rename {.gitea => .jorgejo}/workflows/release.yml (100%) diff --git a/.gitea/workflows/ci.yml b/.jorgejo/workflows/ci.yml similarity index 100% rename from .gitea/workflows/ci.yml rename to .jorgejo/workflows/ci.yml diff --git a/.gitea/workflows/release.yml b/.jorgejo/workflows/release.yml similarity index 100% rename from .gitea/workflows/release.yml rename to .jorgejo/workflows/release.yml -- 2.45.2 From 6bf9dd186619ddd2448c98922c633d1be33d9849 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 5 May 2026 12:23:14 +0200 Subject: [PATCH 56/79] chore: update module path to forge --- README.md | 4 ++-- cli/doctor.go | 6 +++--- cli/doctor_test.go | 6 +++--- cli/resolve_lookup.go | 2 +- cli/resolve_lookup_test.go | 2 +- cli/setup_secret.go | 2 +- cli/setup_secret_test.go | 2 +- cmd/mcp-framework/main.go | 4 ++-- docs/getting-started.md | 4 ++-- docs/minimal-example.md | 4 ++-- docs/scaffolding.md | 2 +- generate/generate.go | 12 ++++++------ generate/generate_test.go | 10 +++++----- go.mod | 2 +- manifest/manifest.go | 2 +- scaffold/scaffold.go | 12 ++++++------ secretstore/bitwarden_test.go | 2 +- secretstore/manifest_open.go | 2 +- secretstore/manifest_open_test.go | 2 +- secretstore/runtime_test.go | 2 +- 20 files changed, 42 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index e874d95..a101e1d 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,13 @@ manifeste `mcp.toml`, diagnostic et auto-update. Dans un projet Go : ```bash -go get gitea.lclr.dev/AI/mcp-framework +go get forge.lclr.dev/AI/mcp-framework ``` Pour utiliser le CLI : ```bash -go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest +go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest ``` ## Créer un projet MCP diff --git a/cli/doctor.go b/cli/doctor.go index 10a508b..df6e768 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -8,9 +8,9 @@ import ( "os" "strings" - "gitea.lclr.dev/AI/mcp-framework/config" - "gitea.lclr.dev/AI/mcp-framework/manifest" - "gitea.lclr.dev/AI/mcp-framework/secretstore" + "forge.lclr.dev/AI/mcp-framework/config" + "forge.lclr.dev/AI/mcp-framework/manifest" + "forge.lclr.dev/AI/mcp-framework/secretstore" ) type DoctorStatus string diff --git a/cli/doctor_test.go b/cli/doctor_test.go index bbdc15e..ba9acba 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -10,9 +10,9 @@ import ( "strings" "testing" - "gitea.lclr.dev/AI/mcp-framework/config" - "gitea.lclr.dev/AI/mcp-framework/manifest" - "gitea.lclr.dev/AI/mcp-framework/secretstore" + "forge.lclr.dev/AI/mcp-framework/config" + "forge.lclr.dev/AI/mcp-framework/manifest" + "forge.lclr.dev/AI/mcp-framework/secretstore" ) type doctorProfile struct { diff --git a/cli/resolve_lookup.go b/cli/resolve_lookup.go index f42fc45..5aa1ff9 100644 --- a/cli/resolve_lookup.go +++ b/cli/resolve_lookup.go @@ -4,7 +4,7 @@ import ( "errors" "os" - "gitea.lclr.dev/AI/mcp-framework/secretstore" + "forge.lclr.dev/AI/mcp-framework/secretstore" ) type KeyLookupFunc func(key string) (string, bool, error) diff --git a/cli/resolve_lookup_test.go b/cli/resolve_lookup_test.go index 32e4d23..be5ad54 100644 --- a/cli/resolve_lookup_test.go +++ b/cli/resolve_lookup_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "gitea.lclr.dev/AI/mcp-framework/secretstore" + "forge.lclr.dev/AI/mcp-framework/secretstore" ) type testSecretStore struct { diff --git a/cli/setup_secret.go b/cli/setup_secret.go index dceaacd..3627805 100644 --- a/cli/setup_secret.go +++ b/cli/setup_secret.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "gitea.lclr.dev/AI/mcp-framework/secretstore" + "forge.lclr.dev/AI/mcp-framework/secretstore" ) type SetupSecretWriteOptions struct { diff --git a/cli/setup_secret_test.go b/cli/setup_secret_test.go index d426459..e1eecfb 100644 --- a/cli/setup_secret_test.go +++ b/cli/setup_secret_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "gitea.lclr.dev/AI/mcp-framework/secretstore" + "forge.lclr.dev/AI/mcp-framework/secretstore" ) func TestWriteSetupSecretVerifiedPersistsAndConfirmsReadability(t *testing.T) { diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go index bcc9345..2947cd5 100644 --- a/cmd/mcp-framework/main.go +++ b/cmd/mcp-framework/main.go @@ -9,8 +9,8 @@ import ( "path/filepath" "strings" - generatepkg "gitea.lclr.dev/AI/mcp-framework/generate" - scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold" + generatepkg "forge.lclr.dev/AI/mcp-framework/generate" + scaffoldpkg "forge.lclr.dev/AI/mcp-framework/scaffold" ) const toolName = "mcp-framework" diff --git a/docs/getting-started.md b/docs/getting-started.md index d4c5dd0..55176a9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -3,7 +3,7 @@ ## Installation ```bash -go get gitea.lclr.dev/AI/mcp-framework +go get forge.lclr.dev/AI/mcp-framework ``` ## CLI de scaffold @@ -11,7 +11,7 @@ go get gitea.lclr.dev/AI/mcp-framework Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go : ```bash -go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest +go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest mcp-framework scaffold init \ --target ./my-mcp \ --module example.com/my-mcp \ diff --git a/docs/minimal-example.md b/docs/minimal-example.md index 6c4c9f5..1e4088b 100644 --- a/docs/minimal-example.md +++ b/docs/minimal-example.md @@ -18,8 +18,8 @@ import ( "os" "example.com/my-mcp/mcpgen" - "gitea.lclr.dev/AI/mcp-framework/config" - "gitea.lclr.dev/AI/mcp-framework/update" + "forge.lclr.dev/AI/mcp-framework/config" + "forge.lclr.dev/AI/mcp-framework/update" ) var version = "dev" diff --git a/docs/scaffolding.md b/docs/scaffolding.md index ee20762..6bace5b 100644 --- a/docs/scaffolding.md +++ b/docs/scaffolding.md @@ -12,7 +12,7 @@ Exemple : ```go result, err := scaffold.Generate(scaffold.Options{ TargetDir: "./my-mcp", - ModulePath: "gitea.lclr.dev/AI/my-mcp", + ModulePath: "forge.lclr.dev/AI/my-mcp", BinaryName: "my-mcp", Description: "Client MCP interne", DefaultProfile: "prod", diff --git a/generate/generate.go b/generate/generate.go index 6128964..a856b2e 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -12,7 +12,7 @@ import ( "strconv" "strings" - "gitea.lclr.dev/AI/mcp-framework/manifest" + "forge.lclr.dev/AI/mcp-framework/manifest" ) var ErrGeneratedFilesOutdated = errors.New("generated files are not up to date") @@ -211,7 +211,7 @@ func renderManifestLoader(packageName, manifestContent string) (string, error) { package %s -import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" +import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest" const embeddedManifest = %s @@ -235,7 +235,7 @@ func renderMetadata(packageName string, manifestFile manifest.File) (string, err package %s -import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" +import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest" const BinaryName = %s const DefaultDescription = %s @@ -275,7 +275,7 @@ import ( "io" "strings" - fwupdate "gitea.lclr.dev/AI/mcp-framework/update" + fwupdate "forge.lclr.dev/AI/mcp-framework/update" ) func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) { @@ -338,7 +338,7 @@ import ( "path/filepath" "strings" - fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" + fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore" ) type SecretStoreOptions struct { @@ -483,7 +483,7 @@ import ( "flag" "strings" - fwcli "gitea.lclr.dev/AI/mcp-framework/cli" + fwcli "forge.lclr.dev/AI/mcp-framework/cli" ) type ConfigFlags struct { diff --git a/generate/generate_test.go b/generate/generate_test.go index e60de93..ee7f2db 100644 --- a/generate/generate_test.go +++ b/generate/generate_test.go @@ -37,7 +37,7 @@ description = "Demo MCP" for _, snippet := range []string{ "// Code generated by mcp-framework generate. DO NOT EDIT.", "package mcpgen", - "import fwmanifest \"gitea.lclr.dev/AI/mcp-framework/manifest\"", + "import fwmanifest \"forge.lclr.dev/AI/mcp-framework/manifest\"", "const embeddedManifest = ", "func LoadManifest(startDir string) (fwmanifest.File, string, error) {", "return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)", @@ -386,7 +386,7 @@ func writeModule(t *testing.T, projectDir string) { t.Fatalf("Abs repo root: %v", err) } - goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n" + goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tforge.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace forge.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n" if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil { t.Fatalf("WriteFile go.mod: %v", err) } @@ -406,10 +406,10 @@ import ( "io" "testing" - fwcli "gitea.lclr.dev/AI/mcp-framework/cli" - fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" + fwcli "forge.lclr.dev/AI/mcp-framework/cli" + fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore" "example.com/generated-demo/mcpgen" - fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" + fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest" ) func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) { diff --git a/go.mod b/go.mod index 1a3e5c8..7ff61b1 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gitea.lclr.dev/AI/mcp-framework +module forge.lclr.dev/AI/mcp-framework go 1.25.0 diff --git a/manifest/manifest.go b/manifest/manifest.go index d82acb3..037e23e 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -9,7 +9,7 @@ import ( "github.com/BurntSushi/toml" - "gitea.lclr.dev/AI/mcp-framework/update" + "forge.lclr.dev/AI/mcp-framework/update" ) const DefaultFile = "mcp.toml" diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index ca72024..be19822 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -1539,12 +1539,12 @@ import ( "strings" "sync" - "gitea.lclr.dev/AI/mcp-framework/bootstrap" - "gitea.lclr.dev/AI/mcp-framework/cli" - "gitea.lclr.dev/AI/mcp-framework/config" - "gitea.lclr.dev/AI/mcp-framework/manifest" - "gitea.lclr.dev/AI/mcp-framework/secretstore" - "gitea.lclr.dev/AI/mcp-framework/update" + "forge.lclr.dev/AI/mcp-framework/bootstrap" + "forge.lclr.dev/AI/mcp-framework/cli" + "forge.lclr.dev/AI/mcp-framework/config" + "forge.lclr.dev/AI/mcp-framework/manifest" + "forge.lclr.dev/AI/mcp-framework/secretstore" + "forge.lclr.dev/AI/mcp-framework/update" ) var embeddedManifest = ` + "`" + `binary_name = "{{.BinaryName}}" diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index cafdfc3..b895c2c 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -15,7 +15,7 @@ import ( "testing" "unicode/utf8" - "gitea.lclr.dev/AI/mcp-framework/manifest" + "forge.lclr.dev/AI/mcp-framework/manifest" ) func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) { diff --git a/secretstore/manifest_open.go b/secretstore/manifest_open.go index 4b0051b..fc576bf 100644 --- a/secretstore/manifest_open.go +++ b/secretstore/manifest_open.go @@ -7,7 +7,7 @@ import ( "path/filepath" "strings" - "gitea.lclr.dev/AI/mcp-framework/manifest" + "forge.lclr.dev/AI/mcp-framework/manifest" ) type ManifestLoader func(startDir string) (manifest.File, string, error) diff --git a/secretstore/manifest_open_test.go b/secretstore/manifest_open_test.go index c60e8c6..bd3c097 100644 --- a/secretstore/manifest_open_test.go +++ b/secretstore/manifest_open_test.go @@ -9,7 +9,7 @@ import ( "github.com/99designs/keyring" - "gitea.lclr.dev/AI/mcp-framework/manifest" + "forge.lclr.dev/AI/mcp-framework/manifest" ) func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) { diff --git a/secretstore/runtime_test.go b/secretstore/runtime_test.go index 23f90f1..b9efdcc 100644 --- a/secretstore/runtime_test.go +++ b/secretstore/runtime_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "gitea.lclr.dev/AI/mcp-framework/manifest" + "forge.lclr.dev/AI/mcp-framework/manifest" ) func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) { -- 2.45.2 From cd0740c75f3b05e18a217a8cbb8427a8561741e0 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 11 May 2026 11:37:35 +0200 Subject: [PATCH 57/79] feat: default login handler in bootstrap When Hooks.Login is nil, Run() now handles the login command directly using LoginBitwarden with BinaryName as the service name, removing the need for glue code in each consumer binary. Co-Authored-By: Claude Sonnet 4.6 --- bootstrap/bootstrap.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 13fdd57..2ee682f 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -8,6 +8,8 @@ import ( "os" "sort" "strings" + + "forge.lclr.dev/AI/mcp-framework/secretstore" ) const ( @@ -154,14 +156,24 @@ func Run(ctx context.Context, opts Options) error { } if handler == nil { - if command == CommandVersion { + switch command { + case CommandVersion: if strings.TrimSpace(normalized.Version) == "" { return ErrVersionRequired } _, err := fmt.Fprintln(normalized.Stdout, normalized.Version) return err + case CommandLogin: + _, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ + ServiceName: normalized.BinaryName, + Stdin: normalized.Stdin, + Stdout: normalized.Stdout, + Stderr: normalized.Stderr, + }) + return err + default: + return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command) } - return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command) } return handler(ctx, Invocation{ -- 2.45.2 From 955c96650ae6ecbb3b0b25d0f8838e85584a801e Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 11 May 2026 11:53:59 +0200 Subject: [PATCH 58/79] fix: rename .forgejo --- {.jorgejo => .forgejo}/workflows/ci.yml | 0 {.jorgejo => .forgejo}/workflows/release.yml | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {.jorgejo => .forgejo}/workflows/ci.yml (100%) rename {.jorgejo => .forgejo}/workflows/release.yml (100%) diff --git a/.jorgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml similarity index 100% rename from .jorgejo/workflows/ci.yml rename to .forgejo/workflows/ci.yml diff --git a/.jorgejo/workflows/release.yml b/.forgejo/workflows/release.yml similarity index 100% rename from .jorgejo/workflows/release.yml rename to .forgejo/workflows/release.yml -- 2.45.2 From 4a7248cfa9fdcab15b144f08ff97497ec5ab85d4 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 12 May 2026 10:30:11 +0200 Subject: [PATCH 59/79] feat(bootstrap): add DisabledCommands option to hide unused commands Commands listed in DisabledCommands are excluded from global help output and return ErrUnknownCommand when invoked or help is requested for them. Co-Authored-By: Claude Sonnet 4.6 --- bootstrap/bootstrap.go | 43 +++++++++++++++++++++-------- bootstrap/bootstrap_test.go | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 2ee682f..e7f1d03 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -52,17 +52,18 @@ type Hooks struct { } type Options struct { - BinaryName string - Description string - Version string - Aliases map[string][]string - AliasDescriptions map[string]string - EnableDoctorAlias bool - Args []string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - Hooks Hooks + BinaryName string + Description string + Version string + Aliases map[string][]string + AliasDescriptions map[string]string + EnableDoctorAlias bool + DisabledCommands []string + Args []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Hooks Hooks } type Invocation struct { @@ -146,6 +147,10 @@ func Run(ctx context.Context, opts Options) error { return printHelp(normalized, "") } + if isCommandDisabled(command, normalized.DisabledCommands) { + return fmt.Errorf("%w: %s", ErrUnknownCommand, command) + } + if command == CommandConfig { return runConfigCommand(ctx, normalized, commandArgs) } @@ -460,6 +465,10 @@ func printHelp(opts Options, command string, args ...[]string) error { return printConfigHelp(opts, commandArgs) } + if isCommandDisabled(command, opts.DisabledCommands) { + return fmt.Errorf("%w: %s", ErrUnknownCommand, command) + } + for _, def := range commands { if def.Name != command { continue @@ -535,6 +544,9 @@ func printGlobalHelp(opts Options) error { } for _, def := range commands { + if isCommandDisabled(def.Name, opts.DisabledCommands) { + continue + } if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", def.Name, def.Description); err != nil { return err } @@ -571,6 +583,15 @@ func printGlobalHelp(opts Options) error { return err } +func isCommandDisabled(command string, disabled []string) bool { + for _, d := range disabled { + if d == command { + return true + } + } + return false +} + func aliasDescription(descriptions map[string]string, name, target string) string { description := strings.TrimSpace(descriptions[name]) if description == "" { diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index ca2c34a..f0fbbeb 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -607,3 +607,58 @@ func TestRunAcceptsGlobalDebugFlagAfterCommand(t *testing.T) { t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1") } } + +func TestDisabledCommandReturnsUnknown(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"login"}, + DisabledCommands: []string{"login"}, + Stdout: &stdout, + Stderr: &stderr, + }) + + if !errors.Is(err, ErrUnknownCommand) { + t.Fatalf("err = %v, want ErrUnknownCommand", err) + } +} + +func TestDisabledCommandHiddenFromHelp(t *testing.T) { + var stdout bytes.Buffer + + err := printGlobalHelp(Options{ + BinaryName: "my-mcp", + DisabledCommands: []string{"login", "setup"}, + Stdout: &stdout, + }) + if err != nil { + t.Fatalf("printGlobalHelp error = %v", err) + } + + out := stdout.String() + if strings.Contains(out, "login") { + t.Fatalf("help should not contain disabled command %q, got:\n%s", "login", out) + } + if strings.Contains(out, "setup") { + t.Fatalf("help should not contain disabled command %q, got:\n%s", "setup", out) + } + if !strings.Contains(out, "mcp") { + t.Fatalf("help should contain enabled command %q, got:\n%s", "mcp", out) + } +} + +func TestDisabledCommandHelpReturnsUnknown(t *testing.T) { + var stdout bytes.Buffer + + err := printHelp(Options{ + BinaryName: "my-mcp", + DisabledCommands: []string{"login"}, + Stdout: &stdout, + }, "login") + + if !errors.Is(err, ErrUnknownCommand) { + t.Fatalf("err = %v, want ErrUnknownCommand", err) + } +} -- 2.45.2 From 9a52b5dce1bbab451534bd0d13dab05cde097942 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 12 May 2026 10:37:46 +0200 Subject: [PATCH 60/79] feat(bootstrap): auto-hide commands with no hook configured Commands are now hidden from help and return ErrUnknownCommand when invoked if their hook is nil (and for version, if Version string is also empty). No explicit DisabledCommands needed for MCPs that don't use login/setup/config/etc. Co-Authored-By: Claude Sonnet 4.6 --- bootstrap/bootstrap.go | 37 +++++++++++++++++++++++++++++++++---- bootstrap/bootstrap_test.go | 30 +++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index e7f1d03..081abab 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -205,6 +205,11 @@ func normalize(opts Options) Options { } opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias) opts.AliasDescriptions = normalizeAliasDescriptions(opts.AliasDescriptions, opts.Aliases, opts.EnableDoctorAlias) + for _, cmd := range autoDisabledCommands(opts) { + if !isCommandDisabled(cmd, opts.DisabledCommands) { + opts.DisabledCommands = append(opts.DisabledCommands, cmd) + } + } return opts } @@ -461,14 +466,14 @@ func printHelp(opts Options, command string, args ...[]string) error { return printGlobalHelp(opts) } - if command == CommandConfig { - return printConfigHelp(opts, commandArgs) - } - if isCommandDisabled(command, opts.DisabledCommands) { return fmt.Errorf("%w: %s", ErrUnknownCommand, command) } + if command == CommandConfig { + return printConfigHelp(opts, commandArgs) + } + for _, def := range commands { if def.Name != command { continue @@ -583,6 +588,30 @@ func printGlobalHelp(opts Options) error { return err } +func autoDisabledCommands(opts Options) []string { + h := opts.Hooks + var disabled []string + if h.Setup == nil { + disabled = append(disabled, CommandSetup) + } + if h.Login == nil { + disabled = append(disabled, CommandLogin) + } + if h.MCP == nil { + disabled = append(disabled, CommandMCP) + } + if h.Config == nil && h.ConfigShow == nil && h.ConfigTest == nil && h.ConfigDelete == nil { + disabled = append(disabled, CommandConfig) + } + if h.Update == nil { + disabled = append(disabled, CommandUpdate) + } + if h.Version == nil && strings.TrimSpace(opts.Version) == "" { + disabled = append(disabled, CommandVersion) + } + return disabled +} + func isCommandDisabled(command string, disabled []string) bool { for _, d := range disabled { if d == command { diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index f0fbbeb..9bd642f 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -91,11 +91,13 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer + noop := func(_ context.Context, _ Invocation) error { return nil } err := Run(context.Background(), Options{ BinaryName: "my-mcp", Args: []string{"config"}, Stdout: &stdout, Stderr: &stderr, + Hooks: Hooks{ConfigShow: noop}, }) if !errors.Is(err, ErrSubcommandRequired) { t.Fatalf("Run error = %v, want ErrSubcommandRequired", err) @@ -148,7 +150,7 @@ func TestRunVersionHookOverridesDefault(t *testing.T) { } } -func TestRunRequiresVersionWithoutVersionHook(t *testing.T) { +func TestRunVersionAutoHiddenWithoutHookOrVersionString(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -158,8 +160,8 @@ func TestRunRequiresVersionWithoutVersionHook(t *testing.T) { Stdout: &stdout, Stderr: &stderr, }) - if !errors.Is(err, ErrVersionRequired) { - t.Fatalf("Run error = %v, want ErrVersionRequired", err) + if !errors.Is(err, ErrUnknownCommand) { + t.Fatalf("Run error = %v, want ErrUnknownCommand", err) } } @@ -167,12 +169,15 @@ func TestRunPrintsGlobalHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer + noop := func(_ context.Context, _ Invocation) error { return nil } err := Run(context.Background(), Options{ BinaryName: "my-mcp", Description: "Binaire MCP de test.", + Version: "v1.2.3", Args: []string{"help"}, Stdout: &stdout, Stderr: &stderr, + Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop}, }) if err != nil { t.Fatalf("Run error = %v", err) @@ -197,12 +202,15 @@ func TestRunPrintsGlobalHelpWhenNoArgs(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer + noop := func(_ context.Context, _ Invocation) error { return nil } err := Run(context.Background(), Options{ BinaryName: "my-mcp", Description: "Binaire MCP de test.", + Version: "v1.2.3", Args: []string{}, Stdout: &stdout, Stderr: &stderr, + Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop}, }) if err != nil { t.Fatalf("Run error = %v", err) @@ -227,11 +235,13 @@ func TestRunPrintsCommandHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer + noop := func(_ context.Context, _ Invocation) error { return nil } err := Run(context.Background(), Options{ BinaryName: "my-mcp", Args: []string{"help", "update"}, Stdout: &stdout, Stderr: &stderr, + Hooks: Hooks{Update: noop}, }) if err != nil { t.Fatalf("Run error = %v", err) @@ -250,11 +260,13 @@ func TestRunPrintsLoginCommandHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer + noop := func(_ context.Context, _ Invocation) error { return nil } err := Run(context.Background(), Options{ BinaryName: "my-mcp", Args: []string{"help", "login"}, Stdout: &stdout, Stderr: &stderr, + Hooks: Hooks{Login: noop}, }) if err != nil { t.Fatalf("Run error = %v", err) @@ -273,11 +285,13 @@ func TestRunPrintsConfigHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer + noop := func(_ context.Context, _ Invocation) error { return nil } err := Run(context.Background(), Options{ BinaryName: "my-mcp", Args: []string{"help", "config"}, Stdout: &stdout, Stderr: &stderr, + Hooks: Hooks{ConfigShow: noop}, }) if err != nil { t.Fatalf("Run error = %v", err) @@ -299,11 +313,13 @@ func TestRunPrintsConfigSubcommandHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer + noop := func(_ context.Context, _ Invocation) error { return nil } err := Run(context.Background(), Options{ BinaryName: "my-mcp", Args: []string{"help", "config", "show"}, Stdout: &stdout, Stderr: &stderr, + Hooks: Hooks{ConfigShow: noop}, }) if err != nil { t.Fatalf("Run error = %v", err) @@ -349,11 +365,13 @@ func TestRunConfigShowReturnsCommandNotConfigured(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer + noop := func(_ context.Context, _ Invocation) error { return nil } err := Run(context.Background(), Options{ BinaryName: "my-mcp", Args: []string{"config", "show"}, Stdout: &stdout, Stderr: &stderr, + Hooks: Hooks{ConfigTest: noop}, }) if !errors.Is(err, ErrCommandNotConfigured) { t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err) @@ -364,11 +382,13 @@ func TestRunConfigReturnsUnknownSubcommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer + noop := func(_ context.Context, _ Invocation) error { return nil } err := Run(context.Background(), Options{ BinaryName: "my-mcp", Args: []string{"config", "sync"}, Stdout: &stdout, Stderr: &stderr, + Hooks: Hooks{ConfigShow: noop}, }) if !errors.Is(err, ErrUnknownSubcommand) { t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err) @@ -412,6 +432,7 @@ func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer + noop := func(_ context.Context, _ Invocation) error { return nil } err := Run(context.Background(), Options{ BinaryName: "my-mcp", Aliases: map[string][]string{ @@ -420,6 +441,7 @@ func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) { Args: []string{"help", "doctor"}, Stdout: &stdout, Stderr: &stderr, + Hooks: Hooks{ConfigTest: noop}, }) if err != nil { t.Fatalf("Run error = %v", err) @@ -435,6 +457,7 @@ func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer + noop := func(_ context.Context, _ Invocation) error { return nil } err := Run(context.Background(), Options{ BinaryName: "my-mcp", Aliases: map[string][]string{ @@ -443,6 +466,7 @@ func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) { Args: []string{"doctor", "--help"}, Stdout: &stdout, Stderr: &stderr, + Hooks: Hooks{ConfigTest: noop}, }) if err != nil { t.Fatalf("Run error = %v", err) -- 2.45.2 From d23d79b6c1c8c00937f9a2cef10aa72e162e1043 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 10:45:51 +0200 Subject: [PATCH 61/79] feat(bootstrap): ajouter DefaultLoginHandler et StandardConfigTestHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DefaultLoginHandler(binaryName) : handler de login Bitwarden prêt à l'emploi avec confirmation. Remplace les réimplémentations identiques dans chaque MCP. - StandardConfigTestHandler(opts) : handler de config test standard sans ManifestCheck. Accepte ConfigCheck, OpenStore, ConnectivityCheck et ExtraChecks. - ManifestCheck dans RunDoctor devient opt-in : inclus uniquement si ManifestDir est fourni (artefact de build, pas de contrainte runtime). - Supprime le handler mort CommandLogin dans bootstrap.Run, désormais remplacé par l'auto-disable et DefaultLoginHandler. Co-Authored-By: Claude Sonnet 4.6 --- bootstrap/bootstrap.go | 10 --- bootstrap/configtest.go | 56 +++++++++++++ bootstrap/configtest_test.go | 155 +++++++++++++++++++++++++++++++++++ bootstrap/login.go | 30 +++++++ bootstrap/login_test.go | 103 +++++++++++++++++++++++ cli/doctor.go | 2 +- cli/doctor_test.go | 18 ++++ 7 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 bootstrap/configtest.go create mode 100644 bootstrap/configtest_test.go create mode 100644 bootstrap/login.go create mode 100644 bootstrap/login_test.go diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 081abab..4f0b509 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -8,8 +8,6 @@ import ( "os" "sort" "strings" - - "forge.lclr.dev/AI/mcp-framework/secretstore" ) const ( @@ -168,14 +166,6 @@ func Run(ctx context.Context, opts Options) error { } _, err := fmt.Fprintln(normalized.Stdout, normalized.Version) return err - case CommandLogin: - _, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ - ServiceName: normalized.BinaryName, - Stdin: normalized.Stdin, - Stdout: normalized.Stdout, - Stderr: normalized.Stderr, - }) - return err default: return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command) } diff --git a/bootstrap/configtest.go b/bootstrap/configtest.go new file mode 100644 index 0000000..9e235a8 --- /dev/null +++ b/bootstrap/configtest.go @@ -0,0 +1,56 @@ +package bootstrap + +import ( + "context" + "fmt" + + fwcli "forge.lclr.dev/AI/mcp-framework/cli" + "forge.lclr.dev/AI/mcp-framework/secretstore" +) + +// StandardConfigTestOptions configure le handler de config test standard. +// Aucun champ n'est obligatoire — omettez ceux qui ne s'appliquent pas à l'application. +type StandardConfigTestOptions struct { + // ConfigCheck vérifie que le fichier de configuration est lisible. + // Construire avec cli.NewConfigCheck(store). + ConfigCheck fwcli.DoctorCheck + + // OpenStore ouvre le secret store pour vérifier sa disponibilité. + // Si fourni, un SecretStoreAvailabilityCheck est automatiquement inclus. + OpenStore func() (secretstore.Store, error) + + // ConnectivityCheck vérifie la connectivité applicative (IMAP, HTTP, etc.). + ConnectivityCheck fwcli.DoctorCheck + + // ExtraChecks contient des vérifications supplémentaires spécifiques à l'application. + ExtraChecks []fwcli.DoctorCheck +} + +// StandardConfigTestHandler retourne un Handler pour la commande config test. +// Il inclut : config (si fourni), secret store (si fourni), connectivité (si fournie), +// checks supplémentaires. Le ManifestCheck est intentionnellement absent : le manifest +// est un artefact de build, pas une contrainte runtime. +func StandardConfigTestHandler(opts StandardConfigTestOptions) Handler { + return func(ctx context.Context, inv Invocation) error { + doctorOpts := fwcli.DoctorOptions{ + ConfigCheck: opts.ConfigCheck, + ConnectivityCheck: opts.ConnectivityCheck, + ExtraChecks: opts.ExtraChecks, + } + + if opts.OpenStore != nil { + doctorOpts.SecretStoreCheck = fwcli.SecretStoreAvailabilityCheck(opts.OpenStore) + } + + report := fwcli.RunDoctor(ctx, doctorOpts) + + if err := fwcli.RenderDoctorReport(inv.Stdout, report); err != nil { + return err + } + + if report.HasFailures() { + return fmt.Errorf("config checks failed") + } + return nil + } +} diff --git a/bootstrap/configtest_test.go b/bootstrap/configtest_test.go new file mode 100644 index 0000000..a0912c7 --- /dev/null +++ b/bootstrap/configtest_test.go @@ -0,0 +1,155 @@ +package bootstrap + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + fwcli "forge.lclr.dev/AI/mcp-framework/cli" + "forge.lclr.dev/AI/mcp-framework/secretstore" +) + +func TestStandardConfigTestHandlerRendersChecks(t *testing.T) { + var stdout bytes.Buffer + + handler := StandardConfigTestHandler(StandardConfigTestOptions{ + ConfigCheck: func(context.Context) fwcli.DoctorResult { + return fwcli.DoctorResult{Name: "config", Status: fwcli.DoctorStatusOK, Summary: "config ok"} + }, + ConnectivityCheck: func(context.Context) fwcli.DoctorResult { + return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusOK, Summary: "reachable"} + }, + }) + + err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout}) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "[OK] config") { + t.Fatalf("stdout = %q, want [OK] config", out) + } + if !strings.Contains(out, "[OK] connectivity") { + t.Fatalf("stdout = %q, want [OK] connectivity", out) + } + if strings.Contains(out, "manifest") { + t.Fatalf("stdout should not contain manifest check, got:\n%s", out) + } +} + +func TestStandardConfigTestHandlerIncludesSecretStoreCheck(t *testing.T) { + var stdout bytes.Buffer + + handler := StandardConfigTestHandler(StandardConfigTestOptions{ + OpenStore: func() (secretstore.Store, error) { + return secretstore.Open(secretstore.Options{ + BackendPolicy: secretstore.BackendEnvOnly, + LookupEnv: func(string) (string, bool) { return "", false }, + }) + }, + }) + + err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout}) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + if !strings.Contains(stdout.String(), "secret-store") { + t.Fatalf("stdout = %q, want secret-store check", stdout.String()) + } +} + +func TestStandardConfigTestHandlerReturnsErrorOnFailure(t *testing.T) { + var stdout bytes.Buffer + + handler := StandardConfigTestHandler(StandardConfigTestOptions{ + ConnectivityCheck: func(context.Context) fwcli.DoctorResult { + return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusFail, Summary: "unreachable"} + }, + }) + + err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout}) + if err == nil { + t.Fatal("handler should return error when checks fail") + } + if !strings.Contains(err.Error(), "config checks failed") { + t.Fatalf("err = %q, want 'config checks failed'", err.Error()) + } + if !strings.Contains(stdout.String(), "[FAIL] connectivity") { + t.Fatalf("stdout = %q, want [FAIL] connectivity", stdout.String()) + } +} + +func TestStandardConfigTestHandlerOmitsManifestCheck(t *testing.T) { + var stdout bytes.Buffer + + handler := StandardConfigTestHandler(StandardConfigTestOptions{ + ExtraChecks: []fwcli.DoctorCheck{ + func(context.Context) fwcli.DoctorResult { + return fwcli.DoctorResult{Name: "custom", Status: fwcli.DoctorStatusOK, Summary: "ok"} + }, + }, + }) + + err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout}) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + if strings.Contains(stdout.String(), "manifest") { + t.Fatalf("manifest check should not appear, got:\n%s", stdout.String()) + } +} + +func TestStandardConfigTestHandlerRunsViaBootstrap(t *testing.T) { + var stdout bytes.Buffer + openCalled := false + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config", "test"}, + Stdout: &stdout, + Hooks: Hooks{ + ConfigTest: StandardConfigTestHandler(StandardConfigTestOptions{ + OpenStore: func() (secretstore.Store, error) { + openCalled = true + return secretstore.Open(secretstore.Options{ + BackendPolicy: secretstore.BackendEnvOnly, + LookupEnv: func(string) (string, bool) { return "", false }, + }) + }, + }), + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + if !openCalled { + t.Fatal("OpenStore should have been called") + } + if !strings.Contains(stdout.String(), "secret-store") { + t.Fatalf("stdout = %q, want secret-store in output", stdout.String()) + } +} + +func TestStandardConfigTestHandlerSecretStoreFailurePropagates(t *testing.T) { + var stdout bytes.Buffer + storeErr := errors.New("bitwarden unavailable") + + handler := StandardConfigTestHandler(StandardConfigTestOptions{ + OpenStore: func() (secretstore.Store, error) { + return nil, storeErr + }, + }) + + err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout}) + if err == nil { + t.Fatal("handler should return error when store fails") + } + if !strings.Contains(stdout.String(), "[FAIL] secret-store") { + t.Fatalf("stdout = %q, want [FAIL] secret-store", stdout.String()) + } +} diff --git a/bootstrap/login.go b/bootstrap/login.go new file mode 100644 index 0000000..fada999 --- /dev/null +++ b/bootstrap/login.go @@ -0,0 +1,30 @@ +package bootstrap + +import ( + "context" + "fmt" + "strings" + + "forge.lclr.dev/AI/mcp-framework/secretstore" +) + +var loginBitwarden = secretstore.LoginBitwarden + +// DefaultLoginHandler retourne un Handler qui authentifie et déverrouille +// Bitwarden, persiste la session BW_SESSION, et confirme le résultat. +// Utiliser comme hook Login lorsqu'aucune logique personnalisée n'est requise. +func DefaultLoginHandler(binaryName string) Handler { + name := strings.TrimSpace(binaryName) + return func(_ context.Context, inv Invocation) error { + if _, err := loginBitwarden(secretstore.BitwardenLoginOptions{ + ServiceName: name, + Stdin: inv.Stdin, + Stdout: inv.Stdout, + Stderr: inv.Stderr, + }); err != nil { + return err + } + _, err := fmt.Fprintf(inv.Stdout, "Session Bitwarden persistée pour %q.\n", name) + return err + } +} diff --git a/bootstrap/login_test.go b/bootstrap/login_test.go new file mode 100644 index 0000000..f609117 --- /dev/null +++ b/bootstrap/login_test.go @@ -0,0 +1,103 @@ +package bootstrap + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "forge.lclr.dev/AI/mcp-framework/secretstore" +) + +func withLoginBitwarden(t *testing.T, fn func(secretstore.BitwardenLoginOptions) (string, error)) { + t.Helper() + previous := loginBitwarden + loginBitwarden = fn + t.Cleanup(func() { loginBitwarden = previous }) +} + +func TestDefaultLoginHandlerPrintsConfirmation(t *testing.T) { + var stdout bytes.Buffer + + withLoginBitwarden(t, func(opts secretstore.BitwardenLoginOptions) (string, error) { + if opts.ServiceName != "my-mcp" { + t.Fatalf("ServiceName = %q, want %q", opts.ServiceName, "my-mcp") + } + return "session-token", nil + }) + + handler := DefaultLoginHandler("my-mcp") + err := handler(context.Background(), Invocation{ + Command: CommandLogin, + Stdout: &stdout, + }) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, `"my-mcp"`) { + t.Fatalf("stdout = %q, want mention of binary name", out) + } + if !strings.Contains(out, "persistée") { + t.Fatalf("stdout = %q, want confirmation message", out) + } +} + +func TestDefaultLoginHandlerPropagatesError(t *testing.T) { + var stdout bytes.Buffer + loginErr := errors.New("vault locked") + + withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) { + return "", loginErr + }) + + handler := DefaultLoginHandler("my-mcp") + err := handler(context.Background(), Invocation{ + Command: CommandLogin, + Stdout: &stdout, + }) + if !errors.Is(err, loginErr) { + t.Fatalf("err = %v, want %v", err, loginErr) + } + if stdout.Len() > 0 { + t.Fatalf("stdout should be empty on error, got %q", stdout.String()) + } +} + +func TestRunUsesDefaultLoginHandlerWhenHookSet(t *testing.T) { + var stdout bytes.Buffer + + withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) { + return "tok", nil + }) + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"login"}, + Stdout: &stdout, + Hooks: Hooks{ + Login: DefaultLoginHandler("my-mcp"), + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + if !strings.Contains(stdout.String(), "persistée") { + t.Fatalf("stdout = %q, want confirmation", stdout.String()) + } +} + +func TestRunLoginAutoHiddenWithoutHook(t *testing.T) { + var stdout bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"login"}, + Stdout: &stdout, + }) + if !errors.Is(err, ErrUnknownCommand) { + t.Fatalf("err = %v, want ErrUnknownCommand", err) + } +} diff --git a/cli/doctor.go b/cli/doctor.go index df6e768..da5d5ac 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -86,7 +86,7 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport { } if options.ManifestCheck != nil { checks = append(checks, options.ManifestCheck) - } else { + } else if strings.TrimSpace(options.ManifestDir) != "" { checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator)) } if options.ConnectivityCheck != nil { diff --git a/cli/doctor_test.go b/cli/doctor_test.go index ba9acba..2982d63 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -198,6 +198,24 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) { } } +func TestRunDoctorOmitsManifestCheckWhenDirNotSet(t *testing.T) { + report := RunDoctor(context.Background(), DoctorOptions{ + ConnectivityCheck: func(context.Context) DoctorResult { + return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "ok"} + }, + // ManifestDir intentionally empty, ManifestCheck intentionally nil. + }) + + for _, r := range report.Results { + if r.Name == "manifest" { + t.Fatalf("manifest check should not be included when ManifestDir is empty, got: %+v", r) + } + } + if len(report.Results) != 1 { + t.Fatalf("result count = %d, want 1 (connectivity only)", len(report.Results)) + } +} + func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) { prev := checkBitwardenReady t.Cleanup(func() { -- 2.45.2 From e6c372bffc3b00b5bb7f36571a3005605553e929 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 10:50:28 +0200 Subject: [PATCH 62/79] refactor(bootstrap): renommer DefaultLoginHandler en BitwardenLoginHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le handler est spécifique au backend Bitwarden CLI. Le nom "Default" suggérait à tort qu'il s'applique à tous les MCPs. Les MCPs sans backend Bitwarden ne définissent pas de hook Login : autoDisabledCommands masque automatiquement la commande. Co-Authored-By: Claude Sonnet 4.6 --- bootstrap/login.go | 12 ++++++++---- bootstrap/login_test.go | 12 ++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/bootstrap/login.go b/bootstrap/login.go index fada999..d3e9bde 100644 --- a/bootstrap/login.go +++ b/bootstrap/login.go @@ -10,10 +10,14 @@ import ( var loginBitwarden = secretstore.LoginBitwarden -// DefaultLoginHandler retourne un Handler qui authentifie et déverrouille -// Bitwarden, persiste la session BW_SESSION, et confirme le résultat. -// Utiliser comme hook Login lorsqu'aucune logique personnalisée n'est requise. -func DefaultLoginHandler(binaryName string) Handler { +// BitwardenLoginHandler retourne un Handler pour la commande login des MCPs +// qui utilisent le backend Bitwarden. Il authentifie et déverrouille le vault, +// persiste BW_SESSION, et confirme le résultat. +// +// N'utiliser que si le MCP déclare secret_store.backend_policy = "bitwarden-cli" +// dans son manifest. Pour les backends env-only ou keyring, ne pas définir de +// hook Login : la commande sera automatiquement masquée. +func BitwardenLoginHandler(binaryName string) Handler { name := strings.TrimSpace(binaryName) return func(_ context.Context, inv Invocation) error { if _, err := loginBitwarden(secretstore.BitwardenLoginOptions{ diff --git a/bootstrap/login_test.go b/bootstrap/login_test.go index f609117..b819463 100644 --- a/bootstrap/login_test.go +++ b/bootstrap/login_test.go @@ -17,7 +17,7 @@ func withLoginBitwarden(t *testing.T, fn func(secretstore.BitwardenLoginOptions) t.Cleanup(func() { loginBitwarden = previous }) } -func TestDefaultLoginHandlerPrintsConfirmation(t *testing.T) { +func TestBitwardenLoginHandlerPrintsConfirmation(t *testing.T) { var stdout bytes.Buffer withLoginBitwarden(t, func(opts secretstore.BitwardenLoginOptions) (string, error) { @@ -27,7 +27,7 @@ func TestDefaultLoginHandlerPrintsConfirmation(t *testing.T) { return "session-token", nil }) - handler := DefaultLoginHandler("my-mcp") + handler := BitwardenLoginHandler("my-mcp") err := handler(context.Background(), Invocation{ Command: CommandLogin, Stdout: &stdout, @@ -45,7 +45,7 @@ func TestDefaultLoginHandlerPrintsConfirmation(t *testing.T) { } } -func TestDefaultLoginHandlerPropagatesError(t *testing.T) { +func TestBitwardenLoginHandlerPropagatesError(t *testing.T) { var stdout bytes.Buffer loginErr := errors.New("vault locked") @@ -53,7 +53,7 @@ func TestDefaultLoginHandlerPropagatesError(t *testing.T) { return "", loginErr }) - handler := DefaultLoginHandler("my-mcp") + handler := BitwardenLoginHandler("my-mcp") err := handler(context.Background(), Invocation{ Command: CommandLogin, Stdout: &stdout, @@ -66,7 +66,7 @@ func TestDefaultLoginHandlerPropagatesError(t *testing.T) { } } -func TestRunUsesDefaultLoginHandlerWhenHookSet(t *testing.T) { +func TestRunUsesBitwardenLoginHandlerWhenHookSet(t *testing.T) { var stdout bytes.Buffer withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) { @@ -78,7 +78,7 @@ func TestRunUsesDefaultLoginHandlerWhenHookSet(t *testing.T) { Args: []string{"login"}, Stdout: &stdout, Hooks: Hooks{ - Login: DefaultLoginHandler("my-mcp"), + Login: BitwardenLoginHandler("my-mcp"), }, }) if err != nil { -- 2.45.2 From f8eb0d3449ab6d00b81df49bbe7d825cc256e69d Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 10:55:52 +0200 Subject: [PATCH 63/79] =?UTF-8?q?docs:=20mettre=20=C3=A0=20jour=20bootstra?= =?UTF-8?q?p-cli=20et=20cli-helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documenter BitwardenLoginHandler, StandardConfigTestHandler et le comportement opt-in de ManifestCheck dans RunDoctor. Co-Authored-By: Claude Sonnet 4.6 --- docs/bootstrap-cli.md | 137 ++++++++++++++++++++++++++++++++++++------ docs/cli-helpers.md | 28 ++++----- 2 files changed, 130 insertions(+), 35 deletions(-) diff --git a/docs/bootstrap-cli.md b/docs/bootstrap-cli.md index 403de43..2b21dc9 100644 --- a/docs/bootstrap-cli.md +++ b/docs/bootstrap-cli.md @@ -1,8 +1,27 @@ # Bootstrap CLI -Le package `bootstrap` reste optionnel : une application peut l'utiliser pour uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique. +Le package `bootstrap` fournit un point d'entrée CLI uniforme pour les binaires +MCP. Il gère le parsing des arguments, l'aide, les alias et le routage vers les +hooks fournis par l'application. -Exemple minimal : +## Commandes disponibles + +| Commande | Description | +|---|---| +| `setup` | Initialiser ou mettre à jour la configuration locale | +| `login` | Authentifier et déverrouiller Bitwarden pour persister `BW_SESSION` | +| `mcp` | Démarrer le serveur MCP | +| `config show` | Afficher la configuration résolue et la provenance des valeurs | +| `config test` | Vérifier la configuration et la connectivité | +| `config delete` | Supprimer un profil local | +| `update` | Auto-update du binaire | +| `version` | Afficher la version | + +Les commandes sans hook correspondant sont automatiquement masquées de l'aide et +retournent une erreur `ErrUnknownCommand`. Exception : `version` affiche +`Options.Version` si fourni, sans hook. + +## Utilisation ```go func main() { @@ -10,26 +29,22 @@ func main() { BinaryName: "my-mcp", Description: "Client MCP", Version: version, - EnableDoctorAlias: true, // expose `doctor` comme alias de `config test` - AliasDescriptions: map[string]string{ - "doctor": "Diagnostiquer la configuration locale.", - }, + EnableDoctorAlias: true, Hooks: bootstrap.Hooks{ Setup: func(ctx context.Context, inv bootstrap.Invocation) error { return runSetup(ctx, inv.Args) }, - Login: func(ctx context.Context, inv bootstrap.Invocation) error { - return runLogin(ctx, inv.Args) - }, + Login: bootstrap.BitwardenLoginHandler("my-mcp"), MCP: func(ctx context.Context, inv bootstrap.Invocation) error { return runMCP(ctx, inv.Args) }, ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error { return runConfigShow(ctx, inv.Args) }, - ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error { - return runConfigTest(ctx, inv.Args) - }, + ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{ + OpenStore: openStore, + ConnectivityCheck: connectivityCheck, + }), Update: func(ctx context.Context, inv bootstrap.Invocation) error { return runUpdate(ctx, inv.Args) }, @@ -41,11 +56,97 @@ func main() { } ``` -Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`. +## Handlers fournis -Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). -La commande `login` est optionnelle et peut être branchée pour gérer un unlock Bitwarden interactif. -La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). -Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. -Le flag global `--debug` est supporté et active le debug des appels Bitwarden +### `BitwardenLoginHandler` + +```go +bootstrap.BitwardenLoginHandler(binaryName string) bootstrap.Handler +``` + +Handler prêt à l'emploi pour la commande `login` des MCPs qui utilisent le +backend Bitwarden CLI. Il lance le flux interactif `bw unlock --raw`, persiste +`BW_SESSION` dans un fichier `0600` sous le répertoire de config utilisateur, et +confirme le résultat. + +À n'utiliser que si le MCP déclare `secret_store.backend_policy = "bitwarden-cli"` +dans son manifest. Pour les autres backends (`env-only`, `keyring-any`), ne pas +définir de hook `Login` : la commande est automatiquement masquée. + +```go +Hooks: bootstrap.Hooks{ + Login: bootstrap.BitwardenLoginHandler(mcpgen.BinaryName), +} +``` + +### `StandardConfigTestHandler` + +```go +bootstrap.StandardConfigTestHandler(opts bootstrap.StandardConfigTestOptions) bootstrap.Handler +``` + +Handler pour `config test` qui exécute un ensemble de checks standards et affiche +un rapport formaté. Aucun champ n'est obligatoire. + +```go +type StandardConfigTestOptions struct { + ConfigCheck cli.DoctorCheck // cli.NewConfigCheck(store) + OpenStore func() (secretstore.Store, error) // check disponibilité secret store + ConnectivityCheck cli.DoctorCheck // check applicatif (HTTP, IMAP…) + ExtraChecks []cli.DoctorCheck +} +``` + +Checks inclus automatiquement selon les champs fournis : + +| Champ | Check résultant | +|---|---| +| `ConfigCheck` | Fichier de configuration lisible | +| `OpenStore` | Secret store disponible | +| `ConnectivityCheck` | Connectivité applicative | +| `ExtraChecks` | Checks supplémentaires | + +Le `ManifestCheck` n'est pas inclus : le manifest est un artefact de build, pas +une contrainte runtime. + +```go +ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{ + ConfigCheck: cli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig]("my-mcp")), + OpenStore: openSecretStore, + ConnectivityCheck: func(ctx context.Context) cli.DoctorResult { + if err := pingBackend(ctx); err != nil { + return cli.DoctorResult{ + Name: "connectivity", + Status: cli.DoctorStatusFail, + Summary: "backend inaccessible", + Detail: err.Error(), + } + } + return cli.DoctorResult{ + Name: "connectivity", + Status: cli.DoctorStatusOK, + Summary: "backend accessible", + } + }, +}), +``` + +Pour un config test applicatif spécifique (appels API, messages ✓/✗), implémenter +un hook `ConfigTest` custom. + +## Options + +| Champ | Description | +|---|---| +| `BinaryName` | Nom du binaire, utilisé dans l'aide et les messages | +| `Description` | Description affichée dans l'aide globale | +| `Version` | Version affichée par `version` sans hook | +| `Args` | Arguments CLI (défaut : `os.Args[1:]`) | +| `Stdin/Stdout/Stderr` | I/O (défaut : `os.Stdin/Stdout/Stderr`) | +| `Aliases` | Alias de commandes | +| `AliasDescriptions` | Descriptions des alias dans l'aide | +| `EnableDoctorAlias` | Active `doctor` comme alias de `config test` | +| `DisabledCommands` | Commandes à masquer explicitement | + +Le flag global `--debug` active le debug des appels Bitwarden (`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`). diff --git a/docs/cli-helpers.md b/docs/cli-helpers.md index 7f59080..fbb5ef8 100644 --- a/docs/cli-helpers.md +++ b/docs/cli-helpers.md @@ -126,8 +126,11 @@ if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil { `ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et `FieldSpec.Sources` permet de définir un ordre spécifique pour un champ. -Le package fournit aussi un socle réutilisable pour une commande `doctor`. -L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), mais peut réutiliser les checks communs et ajouter ses propres hooks : +Le package fournit un socle réutilisable pour une commande `doctor`. +Pour les cas standards (config, secret store, connectivité), préférer +`bootstrap.StandardConfigTestHandler` qui câble `RunDoctor` sans boilerplate. + +Pour un contrôle fin ou un config test impératif, utiliser `RunDoctor` directement : ```go report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ @@ -137,32 +140,19 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ ServiceName: "my-mcp", }) }), - SecretBackendPolicy: secretstore.BackendBitwardenCLI, - BitwardenOptions: cli.BitwardenDoctorOptions{ - LookupEnv: os.LookupEnv, - }, - RequiredSecrets: []cli.DoctorSecret{ - {Name: "api-token", Label: "API token"}, - }, - SecretStoreFactory: func() (secretstore.Store, error) { - return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ - ServiceName: "my-mcp", - }) - }, - ManifestDir: ".", ConnectivityCheck: func(context.Context) cli.DoctorResult { if err := pingBackend(); err != nil { return cli.DoctorResult{ Name: "connectivity", Status: cli.DoctorStatusFail, - Summary: "backend is unreachable", + Summary: "backend inaccessible", Detail: err.Error(), } } return cli.DoctorResult{ Name: "connectivity", Status: cli.DoctorStatusOK, - Summary: "backend is reachable", + Summary: "backend accessible", } }, }) @@ -176,6 +166,10 @@ if report.HasFailures() { } ``` +`ManifestDir` est optionnel. Quand il est fourni, `RunDoctor` inclut un +`ManifestCheck` qui vérifie la présence et la validité de `mcp.toml` dans ce +répertoire. Ne l'inclure que si ce check est pertinent pour l'application. + Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement. Pour le désactiver explicitement : -- 2.45.2 From b9b729e439d9a677eef0cfaef5a1ea3d9d7dd1e3 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 11:00:50 +0200 Subject: [PATCH 64/79] docs: ajouter CHANGELOG.md avec l'historique des versions stables Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 223 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6e1835a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,223 @@ +# Changelog + +Toutes les modifications notables de ce projet sont documentées ici. +Les versions RC (release candidates) ne sont pas listées. + +--- + +## [v1.11.0] — 2026-05-12 + +### Nouvelles fonctionnalités + +- **Bootstrap — masquage automatique des commandes non configurées** : les commandes dont aucun hook n'est défini dans le manifeste sont désormais automatiquement masquées de l'aide CLI, ce qui rend la sortie `--help` plus propre et adaptée à chaque projet. +- **Bootstrap — option `DisabledCommands`** : il est maintenant possible de désactiver explicitement des commandes via l'option `DisabledCommands`, indépendamment de la configuration des hooks. + +--- + +## [v1.10.0] — 2026-05-11 + +### Nouvelles fonctionnalités + +- **Bootstrap — `DefaultLoginHandler`** : un handler de login générique est désormais disponible dans le package bootstrap, utilisable par défaut pour les projets qui n'ont pas besoin d'un flux de connexion personnalisé. + +### Corrections + +- Renommage du dossier `.forgejo` corrigé suite à une erreur de casse. + +--- + +## [v1.9.0] — 2026-05-05 + +### Changements internes + +- **Migration vers Forgejo** : les workflows CI ont été migrés de GitHub Actions vers Forgejo. +- **Mise à jour du module path** : le chemin du module Go a été mis à jour pour pointer vers la forge interne. + +--- + +## [v1.8.2] — 2026-05-02 + +### Performances + +- Suppression d'un appel de sonde Bitwarden inutile lors de la génération de la description runtime, ce qui réduit les appels CLI superflus au démarrage. + +--- + +## [v1.8.1] — 2026-05-02 + +### Performances + +- La vérification de disponibilité de Bitwarden est désormais chargée de manière paresseuse (lazy), évitant une initialisation coûteuse si le secret store n'est pas utilisé. + +--- + +## [v1.8.0] — 2026-05-02 + +### Nouvelles fonctionnalités + +- **Cache Bitwarden chiffré** : les lectures de secrets Bitwarden sont maintenant mises en cache localement sous forme chiffrée, ce qui réduit considérablement le nombre d'appels au CLI `bw` durant une session. +- **Configuration du cache via `mcp.toml`** : les options de cache (durée, activation) sont configurables directement dans le manifeste du projet. +- **Helpers générés** : les helpers de code générés exposent les contrôles de cache Bitwarden, permettant aux projets scaffoldés de bénéficier automatiquement du cache. + +--- + +## [v1.7.0] — 2026-05-02 + +### Nouvelles fonctionnalités + +- **Génération de code depuis le manifeste** : le framework peut désormais générer automatiquement du code Go à partir du `mcp.toml`, incluant les helpers de champs de configuration et le code de glue pour les helpers de manifeste. Cela réduit le boilerplate dans les projets utilisant le framework. + +--- + +## [v1.6.0] — 2026-04-20 + +### Corrections + +- L'invite de connexion Bitwarden s'affiche désormais en rouge lorsque la session est absente, rendant l'état d'erreur plus visible pour l'utilisateur. + +--- + +## [v1.5.1] — 2026-04-16 + +### Améliorations + +- Le script d'installation généré par le scaffold récupère désormais les binaires depuis la dernière release disponible, plutôt qu'une version fixée en dur. + +--- + +## [v1.5.0] — 2026-04-16 + +### Nouvelles fonctionnalités + +- **Fallback runtime embarqué pour les apps scaffoldées** : si le binaire runtime n'est pas trouvé dans l'environnement, les applications générées par le scaffold peuvent désormais utiliser un runtime de fallback embarqué directement dans le manifeste. + +--- + +## [v1.4.2] — 2026-04-15 + +### Nouvelles fonctionnalités + +- **Vérification Ed25519 des artefacts de release** : les artefacts téléchargés lors des mises à jour automatiques sont désormais vérifiés par signature Ed25519, garantissant leur intégrité. + +### Corrections + +- Le mécanisme de mise à jour (`self-update`) rejette maintenant les artefacts HTML (erreurs de redirection ou pages d'erreur) pour éviter d'installer un binaire corrompu. +- Durcissement du runtime scaffold et de la sécurité du processus de mise à jour. + +### Documentation + +- Le README a été réorganisé et la documentation détaillée déplacée dans des fichiers séparés. + +--- + +## [v1.4.1] — 2026-04-15 + +### Améliorations + +- L'assistant d'installation généré par le scaffold est aligné avec le dernier flux TUI, garantissant la cohérence entre le code généré et le comportement attendu. + +--- + +## [v1.4.0] — 2026-04-15 + +### Nouvelles fonctionnalités + +- **Assistant d'installation TUI pour Claude et Codex** : le scaffold injecte désormais un wizard interactif (TUI) dans le script `install.sh` des projets générés, guidant l'utilisateur lors de la première installation avec des étapes de configuration pour Claude et Codex. + +--- + +## [v1.3.2] — 2026-04-15 + +### Corrections + +- Revert des fonctionnalités `build` unifiée et matrice CI introduites en cours de cycle, jugées non stables pour cette release. + +--- + +## [v1.3.1] — 2026-04-14 + +### Nouvelles fonctionnalités + +- **CLI — vérification doctor sur les champs de profil** : un helper réutilisable permet de valider les champs de configuration résolus depuis plusieurs sources (env, fichier, défaut) lors du diagnostic `doctor`. +- **CLI — lookup multi-sources** : ajout d'un helper de résolution de valeur avec traçabilité de la source (d'où vient la valeur résolue). +- **Secretstore — helper de manifeste runtime** : ajout d'un helper facilitant l'ouverture du backend secret store depuis le manifeste runtime. +- **Bootstrap — expansion d'alias de commandes** : les commandes bootstrap peuvent maintenant définir des alias qui sont développés automatiquement. + +--- + +## [v1.3.0] — 2026-04-14 + +### Nouvelles fonctionnalités + +- **Commande `scaffold init`** : nouvelle commande CLI pour initialiser un projet MCP depuis zéro via le scaffold. +- **Générateur de scaffold MCP** : ajout d'un générateur de projet binaire MCP complet, produisant la structure de fichiers, le manifeste `mcp.toml`, et le code de démarrage. + +--- + +## [v1.2.1] — 2026-04-14 + +### Améliorations + +- Les commandes `config show` et `config test` générées par le bootstrap suivent désormais une structure standardisée cohérente entre les projets. + +### Corrections + +- La CI construit le changelog depuis le dernier tag de release stable (et non depuis un tag RC). + +--- + +## [v1.2.0] — 2026-04-14 + +### Documentation + +- Mise en place de la convention de nommage des branches d'amélioration dans les instructions agents du dépôt. + +--- + +## [v1.1.0] — 2026-04-13 + +### Nouvelles fonctionnalités + +- **Migrations de configuration versionnées** : le framework gère désormais les migrations de configuration entre versions, permettant aux projets d'évoluer leur schéma de config sans casser les installations existantes. +- **Secrets structurés et politiques de backend** : support des secrets structurés (objets, non plus uniquement des chaînes) et des politiques de sélection de backend secret store par champ. + +### Documentation + +- Ajout des instructions de workflow du dépôt. + +--- + +## [v1.0.0] — 2026-04-13 + +Première release stable du framework. + +### Fonctionnalités initiales + +- **Framework MCP réutilisable** : socle commun pour construire des serveurs MCP en Go, avec gestion du cycle de vie, configuration, et intégration des outils. +- **Loader de manifeste TOML** : chargement de la configuration projet depuis un fichier `mcp.toml`. +- **Package de mise à jour** : mécanisme de self-update découplé, pilotable par des drivers de forge (GitLab, Forgejo, etc.) avec validation de checksum. +- **Bootstrap CLI optionnel** : package permettant de bootstrapper rapidement une CLI pour un projet MCP, avec commandes `config`, `login`, `doctor`, et `update` préconfigurées. +- **Workflow de release CI** : pipeline de release automatisée avec génération de changelog et publication des artefacts. + +--- + +[v1.11.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.11.0 +[v1.10.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.10.0 +[v1.9.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.9.0 +[v1.8.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.2 +[v1.8.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.1 +[v1.8.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.0 +[v1.7.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.7.0 +[v1.6.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.6.0 +[v1.5.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.5.1 +[v1.5.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.5.0 +[v1.4.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.2 +[v1.4.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.1 +[v1.4.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.0 +[v1.3.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.2 +[v1.3.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.1 +[v1.3.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.0 +[v1.2.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.2.1 +[v1.2.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.2.0 +[v1.1.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.1.0 +[v1.0.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.0.0 -- 2.45.2 From 3a61387215fd8b95216df8bf8f3fc67a9f514772 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 11:02:59 +0200 Subject: [PATCH 65/79] docs(changelog): supprimer le texte d'introduction Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e1835a..a4c09a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,5 @@ # Changelog -Toutes les modifications notables de ce projet sont documentées ici. -Les versions RC (release candidates) ne sont pas listées. - ---- - ## [v1.11.0] — 2026-05-12 ### Nouvelles fonctionnalités -- 2.45.2 From 4e2bfbee02e977a6d0c712276903219c487fb059 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 11:15:21 +0200 Subject: [PATCH 66/79] ci(release): alimenter les notes de release depuis [Unreleased] dans CHANGELOG.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit À chaque tag stable, la CI extrait la section [Unreleased], l'utilise comme notes de release Forgejo, renomme la section avec la version et la date, puis commite le CHANGELOG.md mis à jour sur main. Les tags RC utilisent le contenu [Unreleased] pour les notes mais ne modifient pas le fichier. Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/workflows/release.yml | 71 ++++++++++++++++++++++------------ CHANGELOG.md | 2 + 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 51d46b6..4572a44 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,7 +6,7 @@ name: Release - "**" permissions: - contents: read + contents: write releases: write jobs: @@ -19,38 +19,59 @@ jobs: with: fetch-depth: 0 - - name: Build changelog + - name: Extract changelog and update CHANGELOG.md id: changelog + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} shell: bash run: | set -euo pipefail current_tag="${GITHUB_REF_NAME}" - previous_stable_tag="" + today=$(date +%Y-%m-%d) - if previous_stable_tag="$( - git describe --tags --abbrev=0 \ - --exclude '*-rc*' \ - --exclude '*-beta*' \ - --exclude '*-alpha*' \ - "${current_tag}^" 2>/dev/null - )"; then - range="${previous_stable_tag}..${current_tag}" - { - printf '## Changelog\n\n' - printf 'Changes since `%s`.\n\n' "${previous_stable_tag}" - git log --reverse --pretty=format:'- %h %s' "${range}" - printf '\n' - } >CHANGELOG.md - else - { - printf '## Changelog\n\n' - printf 'Initial release.\n\n' - git log --reverse --pretty=format:'- %h %s' "${current_tag}" - printf '\n' - } >CHANGELOG.md + # Extract content of [Unreleased] section (non-empty lines) + release_notes=$(awk '/^## \[Unreleased\]/{found=1; next} found && /^## \[/{exit} found{print}' CHANGELOG.md | sed '/^[[:space:]]*$/d') + + if [ -z "${release_notes}" ]; then + release_notes="Voir les commits pour le détail des changements." fi + printf '%s\n' "${release_notes}" > release_notes.md + + # For stable releases: rename [Unreleased] and insert a new empty section + case "${current_tag}" in + *-rc*|*-beta*|*-alpha*) + echo "Pre-release tag — CHANGELOG.md non modifié" + ;; + *) + cat > /tmp/update_changelog.py << 'PYEOF' + import os + + version = os.environ['VERSION'] + today = os.environ['TODAY'] + + with open('CHANGELOG.md', 'r') as f: + content = f.read() + + content = content.replace('## [Unreleased]', f'## [{version}] — {today}', 1) + content = content.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n', 1) + + with open('CHANGELOG.md', 'w') as f: + f.write(content) + PYEOF + + VERSION="${current_tag}" TODAY="${today}" python3 /tmp/update_changelog.py + + git config user.name "CI" + git config user.email "ci@forge.lclr.dev" + git remote set-url origin "https://x-token:${GITEA_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" + git add CHANGELOG.md + git commit -m "chore(changelog): release ${current_tag}" + git push origin HEAD:main + ;; + esac + - name: Create or update release env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} @@ -73,7 +94,7 @@ jobs: sed ':a;N;$!ba;s/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g;s/\r//g;s/\n/\\n/g' } - body="$(json_escape < CHANGELOG.md)" + body="$(json_escape < release_notes.md)" payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \ "${current_tag}" \ "${current_tag}" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c09a9..cbdf6ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [v1.11.0] — 2026-05-12 ### Nouvelles fonctionnalités -- 2.45.2 From ea3a37559a73b70b542cdcec31574b521d792c9c Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 11:18:27 +0200 Subject: [PATCH 67/79] ci(release): remplacer le script Python par sed/awk Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/workflows/release.yml | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 4572a44..e298d13 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -45,23 +45,17 @@ jobs: echo "Pre-release tag — CHANGELOG.md non modifié" ;; *) - cat > /tmp/update_changelog.py << 'PYEOF' - import os + # Rename [Unreleased] → version header + sed -i "s/^## \[Unreleased\]$/## [${current_tag}] — ${today}/" CHANGELOG.md - version = os.environ['VERSION'] - today = os.environ['TODAY'] + # Insert new empty [Unreleased] section after "# Changelog" + sed -i "s/^# Changelog$/# Changelog\n\n## [Unreleased]/" CHANGELOG.md - with open('CHANGELOG.md', 'r') as f: - content = f.read() - - content = content.replace('## [Unreleased]', f'## [{version}] — {today}', 1) - content = content.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n', 1) - - with open('CHANGELOG.md', 'w') as f: - f.write(content) - PYEOF - - VERSION="${current_tag}" TODAY="${today}" python3 /tmp/update_changelog.py + # Insert reference link before the first existing [vX...] link + awk -v tag="${current_tag}" -v url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${current_tag}" ' + !inserted && /^\[v/ { print "[" tag "]: " url; inserted=1 } + { print } + ' CHANGELOG.md > CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md git config user.name "CI" git config user.email "ci@forge.lclr.dev" -- 2.45.2 From 39b2bfbcf9e7b8d0299418f4c3a119c7a2f63eb6 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 11:21:38 +0200 Subject: [PATCH 68/79] =?UTF-8?q?docs(changelog):=20compl=C3=A9ter=20la=20?= =?UTF-8?q?section=20Unreleased?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbdf6ee..a976841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Nouvelles fonctionnalités + +- **Bootstrap — `DefaultLoginHandler`** : handler de login Bitwarden prêt à l'emploi avec confirmation, évitant de réimplémenter le même code dans chaque MCP. +- **Bootstrap — `StandardConfigTestHandler`** : handler de config test standard sans `ManifestCheck`. Accepte `ConfigCheck`, `OpenStore`, `ConnectivityCheck` et `ExtraChecks`. +- **CLI — `ManifestCheck` opt-in dans `RunDoctor`** : le check de manifeste n'est inclus que si `ManifestDir` est fourni, supprimant une contrainte runtime inutile. + +### Changements cassants + +- **Bootstrap — `DefaultLoginHandler` renommé en `BitwardenLoginHandler`** : le nom précédent suggérait à tort que le handler s'applique à tous les MCPs. Les projets sans backend Bitwarden ne définissent pas de hook Login — la commande est masquée automatiquement par `autoDisabledCommands`. + ## [v1.11.0] — 2026-05-12 ### Nouvelles fonctionnalités -- 2.45.2 From 9ac814fda401df6684361395e351d093c636226e Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 11:22:15 +0200 Subject: [PATCH 69/79] =?UTF-8?q?docs(agents):=20ajouter=20instruction=20d?= =?UTF-8?q?e=20mise=20=C3=A0=20jour=20du=20CHANGELOG=20apr=C3=A8s=20chaque?= =?UTF-8?q?=20dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute CLAUDE.md comme symlink vers AGENTS.md. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 8 ++++++++ CLAUDE.md | 1 + 2 files changed, 9 insertions(+) create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 9cceb6c..8a8516a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,3 +60,11 @@ Avant d'ouvrir ou de mettre à jour une PR : ## Commits Conserver des messages de commit au format conventional commits, conformément aux règles globales. + +## Changelog + +Après chaque développement fonctionnel, mettre à jour la section `## [Unreleased]` du `CHANGELOG.md` avec une description claire des changements apportés. + +Ne pas logger les changements purement liés à la CI ou au changelog lui-même. + +La CI se charge de versioner automatiquement la section `[Unreleased]` lors du push d'un tag stable. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file -- 2.45.2 From 64671fc8b2deb50bb9893c1ec6c4ebaea26006b9 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 11:24:08 +0200 Subject: [PATCH 70/79] =?UTF-8?q?ci(release):=20ajouter=20la=20liste=20des?= =?UTF-8?q?=20commits=20apr=C3=A8s=20les=20notes=20CHANGELOG=20dans=20la?= =?UTF-8?q?=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/workflows/release.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index e298d13..170f5c7 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -84,11 +84,32 @@ jobs: ;; esac + # Build commit log since previous stable tag + previous_stable_tag="" + if previous_stable_tag="$( + git describe --tags --abbrev=0 \ + --exclude '*-rc*' \ + --exclude '*-beta*' \ + --exclude '*-alpha*' \ + "${current_tag}^" 2>/dev/null + )"; then + range="${previous_stable_tag}..${current_tag}" + else + range="${current_tag}" + fi + + { + cat release_notes.md + printf '\n\n## Commits\n\n' + git log --reverse --pretty=format:'- %h %s' "${range}" + printf '\n' + } > release_body.md + json_escape() { sed ':a;N;$!ba;s/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g;s/\r//g;s/\n/\\n/g' } - body="$(json_escape < release_notes.md)" + body="$(json_escape < release_body.md)" payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \ "${current_tag}" \ "${current_tag}" \ -- 2.45.2 From 92b63fe83d5a44b16ad45f6189cc2119406054c6 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 11:30:02 +0200 Subject: [PATCH 71/79] ci(release): ignorer les pushes de branches avec un guard sur refs/tags/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le trigger 'on: push: tags: "**"' dans Forgejo déclenche aussi sur les pushes de branches. Le guard 'if: startsWith(github.ref, refs/tags/)' assure que le job ne tourne que sur de vrais tags. Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 170f5c7..13f0be5 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -12,6 +12,7 @@ permissions: jobs: release: runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') steps: - name: Checkout repository -- 2.45.2 From 267b83bd0c12fe4c5275528c8986b1b1a854bd94 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 11:31:42 +0200 Subject: [PATCH 72/79] ci(release): corriger la construction de l'URL git pour le push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GITHUB_SERVER_URL vaut http://forgejo:3000 (réseau interne Docker). Extraire scheme et host séparément pour reconstruire l'URL correctement. Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 13f0be5..adad9f8 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -60,7 +60,9 @@ jobs: git config user.name "CI" git config user.email "ci@forge.lclr.dev" - git remote set-url origin "https://x-token:${GITEA_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" + scheme="${GITHUB_SERVER_URL%%://*}" + host="${GITHUB_SERVER_URL#*://}" + git remote set-url origin "${scheme}://x-token:${GITEA_TOKEN}@${host}/${GITHUB_REPOSITORY}.git" git add CHANGELOG.md git commit -m "chore(changelog): release ${current_tag}" git push origin HEAD:main -- 2.45.2 From 200674778bde0cdbdf5550db5b45ca64e2f17a40 Mon Sep 17 00:00:00 2001 From: CI Date: Wed, 13 May 2026 09:32:24 +0000 Subject: [PATCH 73/79] chore(changelog): release v1.12.0 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a976841..8ba4672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [v1.12.0] — 2026-05-13 + ### Nouvelles fonctionnalités - **Bootstrap — `DefaultLoginHandler`** : handler de login Bitwarden prêt à l'emploi avec confirmation, évitant de réimplémenter le même code dans chaque MCP. @@ -208,6 +210,7 @@ Première release stable du framework. --- +[v1.12.0]: http://forgejo:3000/AI/mcp-framework/releases/tag/v1.12.0 [v1.11.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.11.0 [v1.10.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.10.0 [v1.9.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.9.0 -- 2.45.2 From 078aa172856423c093cd73722252ad5cfc13f67d Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 12:23:18 +0200 Subject: [PATCH 74/79] =?UTF-8?q?fix(secretstore):=20relire=20la=20session?= =?UTF-8?q?=20Bitwarden=20depuis=20le=20fichier=20avant=20chaque=20op?= =?UTF-8?q?=C3=A9ration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quand un MCP appelle login/unlock, le token est écrit dans le fichier de session mais les autres MCPs conservent leur token obsolète dans l'environnement du processus. Désormais, bitwardenStore.ensureReady() appelle refreshSessionEnv() qui relit le fichier avant chaque vérification, ce qui permet à tous les MCPs de rester opérationnels après une rotation de session. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 4 ++ secretstore/bitwarden.go | 9 ++++ secretstore/bitwarden_test.go | 87 +++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ba4672..039b5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - **Bootstrap — `StandardConfigTestHandler`** : handler de config test standard sans `ManifestCheck`. Accepte `ConfigCheck`, `OpenStore`, `ConnectivityCheck` et `ExtraChecks`. - **CLI — `ManifestCheck` opt-in dans `RunDoctor`** : le check de manifeste n'est inclus que si `ManifestDir` est fourni, supprimant une contrainte runtime inutile. +### Corrections + +- **Secretstore — rafraîchissement automatique de la session Bitwarden** : chaque MCP relit désormais le fichier de session avant toute opération Bitwarden. Cela résout le cas où débloquer le vault depuis un MCP rendait les autres inopérants (session obsolète ou absente de l'environnement). + ### Changements cassants - **Bootstrap — `DefaultLoginHandler` renommé en `BitwardenLoginHandler`** : le nom précédent suggérait à tort que le handler s'applique à tous les MCPs. Les projets sans backend Bitwarden ne définissent pas de hook Login — la commande est masquée automatiquement par `autoDisabledCommands`. diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 9c580b8..c192db4 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -384,6 +384,7 @@ func (s *bitwardenStore) scopedName(name string) string { } func (s *bitwardenStore) ensureReady() error { + s.refreshSessionEnv() return verifyBitwardenCLIReady(Options{ BitwardenCommand: s.command, BitwardenDebug: s.debug, @@ -392,6 +393,14 @@ func (s *bitwardenStore) ensureReady() error { }) } +func (s *bitwardenStore) refreshSessionEnv() { + session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: s.serviceName}) + if err != nil || strings.TrimSpace(session) == "" { + return + } + _ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session)) +} + type bitwardenResolvedItem struct { item bitwardenListItem payload map[string]any diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index b895c2c..705a467 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -66,6 +66,9 @@ func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T) } func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) { + withBitwardenUserConfigDir(t, func() (string, error) { + return t.TempDir(), nil + }) fakeCLI := newFakeBitwardenCLI("bw") withBitwardenRunner(t, fakeCLI.run) @@ -572,6 +575,9 @@ func TestBitwardenStoreDeleteSecretInvalidatesCache(t *testing.T) { } func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) { + withBitwardenUserConfigDir(t, func() (string, error) { + return t.TempDir(), nil + }) store := &bitwardenStore{command: "bw", serviceName: "email-mcp"} withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" { @@ -728,6 +734,84 @@ func stripANSIControlSequences(value string) string { return strings.ReplaceAll(noANSI, "\r", "") } +func TestBitwardenStorePicksUpSessionRotatedByAnotherProcess(t *testing.T) { + t.Setenv("BW_SESSION", "old-session") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + withBitwardenUserCacheDir(t, func() (string, error) { + return t.TempDir(), nil + }) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "new-session"); err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + if _, err := store.GetSecret("api-token"); err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if got := os.Getenv("BW_SESSION"); got != "new-session" { + t.Fatalf("BW_SESSION = %q, want new-session after session rotation", got) + } +} + +func TestBitwardenStorePicksUpSessionFromFileWhenEnvIsEmpty(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + withBitwardenUserCacheDir(t, func() (string, error) { + return t.TempDir(), nil + }) + fakeCLI := newFakeBitwardenCLI("bw") + fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{ + ID: "item-1", + Name: "email-mcp/api-token", + Secret: "secret-v1", + MarkerService: "email-mcp", + MarkerSecretName: "api-token", + } + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "file-session"); err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + if _, err := store.GetSecret("api-token"); err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if got := os.Getenv("BW_SESSION"); got != "file-session" { + t.Fatalf("BW_SESSION = %q, want file-session loaded from file after login by another process", got) + } +} + func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") @@ -768,6 +852,9 @@ func withBitwardenSession(t *testing.T) { withBitwardenUserCacheDir(t, func() (string, error) { return t.TempDir(), nil }) + withBitwardenUserConfigDir(t, func() (string, error) { + return t.TempDir(), nil + }) } func withBitwardenRunner( -- 2.45.2 From 90dbed4d370f9d667a574ca111bd5627eb395d53 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 12:30:58 +0200 Subject: [PATCH 75/79] =?UTF-8?q?fix(secretstore):=20=C3=A9viter=20l'inval?= =?UTF-8?q?idation=20crois=C3=A9e=20des=20sessions=20Bitwarden=20entre=20M?= =?UTF-8?q?CPs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quand deux MCPs appellaient login, le second appelait bw unlock et générait un nouveau token, invalidant celui du premier. Deux mécanismes corrigent ça : 1. LoginBitwarden ne relance plus bw unlock si le vault est déjà unlocked et qu'une session existe (env, fichier service, ou fichier partagé). 2. Le login écrit le token dans ~/.config/mcp-framework/bw-session (partagé) en plus du fichier service-spécifique. Les autres MCPs lisent ce fichier en priorité via refreshSessionEnv avant chaque opération Bitwarden. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 2 +- secretstore/bitwarden.go | 13 +++- secretstore/bitwarden_session.go | 41 +++++++++- secretstore/bitwarden_session_test.go | 106 ++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 039b5fc..f407c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### Corrections -- **Secretstore — rafraîchissement automatique de la session Bitwarden** : chaque MCP relit désormais le fichier de session avant toute opération Bitwarden. Cela résout le cas où débloquer le vault depuis un MCP rendait les autres inopérants (session obsolète ou absente de l'environnement). +- **Secretstore — isolation des sessions Bitwarden entre MCPs** : `bw unlock` n'est plus appelé si le vault est déjà ouvert et qu'une session valide existe (env, fichier service, ou fichier partagé). Le login écrit désormais dans un fichier partagé (`~/.config/mcp-framework/bw-session`) que les autres MCPs lisent en priorité, évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit aussi ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage. ### Changements cassants diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index c192db4..792f223 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -394,11 +394,18 @@ func (s *bitwardenStore) ensureReady() error { } func (s *bitwardenStore) refreshSessionEnv() { - session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: s.serviceName}) - if err != nil || strings.TrimSpace(session) == "" { + // Prefer the shared file: it holds the latest session from any MCP login. + if session, err := LoadBitwardenSession(BitwardenSessionOptions{ + ServiceName: bitwardenSharedSessionName, + }); err == nil && strings.TrimSpace(session) != "" { + _ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session)) return } - _ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session)) + if session, err := LoadBitwardenSession(BitwardenSessionOptions{ + ServiceName: s.serviceName, + }); err == nil && strings.TrimSpace(session) != "" { + _ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session)) + } } type bitwardenResolvedItem struct { diff --git a/secretstore/bitwarden_session.go b/secretstore/bitwarden_session.go index ff4d814..3b20af4 100644 --- a/secretstore/bitwarden_session.go +++ b/secretstore/bitwarden_session.go @@ -9,7 +9,10 @@ import ( "strings" ) -const bitwardenSessionFileName = "bw-session" +const ( + bitwardenSessionFileName = "bw-session" + bitwardenSharedSessionName = "mcp-framework" +) var bitwardenUserConfigDir = os.UserConfigDir @@ -156,7 +159,20 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) { if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil { return "", fmt.Errorf("login to bitwarden CLI: %w", err) } - case "locked", "unlocked": + case "unlocked": + // Vault is already unlocked. Reuse an existing session to avoid calling + // bw unlock again, which would generate a new token and invalidate the + // tokens held by other running MCP processes. + if existing := loadAnyBitwardenSession(serviceName); existing != "" { + if err := os.Setenv(bitwardenSessionEnvName, existing); err != nil { + return "", fmt.Errorf("set %s from existing session: %w", bitwardenSessionEnvName, err) + } + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, existing); err != nil { + return "", fmt.Errorf("persist bitwarden session: %w", err) + } + return existing, nil + } + case "locked": default: return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status) } @@ -178,10 +194,31 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) { if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil { return "", fmt.Errorf("persist bitwarden session: %w", err) } + // Save to shared file so other MCP processes can reuse this session + // without needing to call bw unlock themselves. + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, session); err != nil { + return "", fmt.Errorf("persist shared bitwarden session: %w", err) + } return session, nil } +// loadAnyBitwardenSession looks for an existing valid session in: the process +// environment, the service-specific file, and the shared file written by any +// MCP login. Returns the first non-empty session found. +func loadAnyBitwardenSession(serviceName string) string { + if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" { + return strings.TrimSpace(session) + } + if session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}); err == nil { + return session + } + if session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}); err == nil { + return session + } + return "" +} + func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) { serviceName := strings.TrimSpace(options.ServiceName) if serviceName == "" { diff --git a/secretstore/bitwarden_session_test.go b/secretstore/bitwarden_session_test.go index 3b8e3e4..8a8e54d 100644 --- a/secretstore/bitwarden_session_test.go +++ b/secretstore/bitwarden_session_test.go @@ -207,6 +207,112 @@ func TestLoadBitwardenSessionReturnsNotFoundWhenFileMissing(t *testing.T) { } } +func TestLoginBitwardenPersistsToSharedFileAfterUnlock(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"locked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { + if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" { + return []byte("shared-session\n"), nil + } + return nil, fmt.Errorf("unexpected interactive args: %v", args) + }) + + if _, err := LoginBitwarden(BitwardenLoginOptions{ + ServiceName: "graylog-mcp", + BitwardenCommand: "bw", + }); err != nil { + t.Fatalf("LoginBitwarden returned error: %v", err) + } + + shared, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}) + if err != nil { + t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err) + } + if shared != "shared-session" { + t.Fatalf("shared session = %q, want shared-session", shared) + } +} + +func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithEnvSession(t *testing.T) { + t.Setenv("BW_SESSION", "existing-session") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"unlocked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { + return nil, fmt.Errorf("bw unlock must not be called when vault is already unlocked with a session: %v", args) + }) + + session, err := LoginBitwarden(BitwardenLoginOptions{ + ServiceName: "email-mcp", + BitwardenCommand: "bw", + }) + if err != nil { + t.Fatalf("LoginBitwarden returned error: %v", err) + } + if session != "existing-session" { + t.Fatalf("session = %q, want existing-session", session) + } +} + +func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithSharedSession(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + // graylog-mcp logged in earlier and wrote to the shared file + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "graylog-session"); err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"unlocked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { + return nil, fmt.Errorf("bw unlock must not be called when shared session exists: %v", args) + }) + + session, err := LoginBitwarden(BitwardenLoginOptions{ + ServiceName: "email-mcp", + BitwardenCommand: "bw", + }) + if err != nil { + t.Fatalf("LoginBitwarden returned error: %v", err) + } + if session != "graylog-session" { + t.Fatalf("session = %q, want graylog-session (from shared file)", session) + } + + // The shared session must also be persisted to the service-specific file + persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if err != nil { + t.Fatalf("LoadBitwardenSession returned error: %v", err) + } + if persisted != "graylog-session" { + t.Fatalf("email-mcp persisted session = %q, want graylog-session", persisted) + } +} + func withBitwardenInteractiveRunner( t *testing.T, runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error), -- 2.45.2 From 7c016e8c5e5bfb1f8feb32cfeb3c859f37c9b559 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 13:56:10 +0200 Subject: [PATCH 76/79] =?UTF-8?q?refactor(secretstore):=20supprimer=20le?= =?UTF-8?q?=20fichier=20de=20session=20service-sp=C3=A9cifique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le fichier ~/.config//bw-session est redondant depuis l'introduction du fichier partagé mcp-framework. On n'écrit plus que dans le partagé et on lit uniquement depuis lui dans refreshSessionEnv et loadAnyBitwardenSession. EnsureBitwardenSessionEnv tente le fichier service-spécifique en premier (rétrocompat) puis bascule sur le partagé. Co-Authored-By: Claude Sonnet 4.6 --- secretstore/bitwarden.go | 13 +++---------- secretstore/bitwarden_session.go | 25 ++++++++++--------------- secretstore/bitwarden_session_test.go | 6 +++--- secretstore/bitwarden_test.go | 4 ++-- 4 files changed, 18 insertions(+), 30 deletions(-) diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 792f223..2d4d22c 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -394,18 +394,11 @@ func (s *bitwardenStore) ensureReady() error { } func (s *bitwardenStore) refreshSessionEnv() { - // Prefer the shared file: it holds the latest session from any MCP login. - if session, err := LoadBitwardenSession(BitwardenSessionOptions{ - ServiceName: bitwardenSharedSessionName, - }); err == nil && strings.TrimSpace(session) != "" { - _ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session)) + session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}) + if err != nil || strings.TrimSpace(session) == "" { return } - if session, err := LoadBitwardenSession(BitwardenSessionOptions{ - ServiceName: s.serviceName, - }); err == nil && strings.TrimSpace(session) != "" { - _ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session)) - } + _ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session)) } type bitwardenResolvedItem struct { diff --git a/secretstore/bitwarden_session.go b/secretstore/bitwarden_session.go index 3b20af4..e72f01e 100644 --- a/secretstore/bitwarden_session.go +++ b/secretstore/bitwarden_session.go @@ -111,10 +111,14 @@ func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) { session, err := LoadBitwardenSession(options) if err != nil { - if errors.Is(err, ErrNotFound) { + if !errors.Is(err, ErrNotFound) { + return false, err + } + // Service-specific file not found; try the shared file written by any MCP login. + session, err = LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}) + if err != nil { return false, nil } - return false, err } if err := os.Setenv(bitwardenSessionEnvName, session); err != nil { @@ -163,7 +167,7 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) { // Vault is already unlocked. Reuse an existing session to avoid calling // bw unlock again, which would generate a new token and invalidate the // tokens held by other running MCP processes. - if existing := loadAnyBitwardenSession(serviceName); existing != "" { + if existing := loadAnyBitwardenSession(); existing != "" { if err := os.Setenv(bitwardenSessionEnvName, existing); err != nil { return "", fmt.Errorf("set %s from existing session: %w", bitwardenSessionEnvName, err) } @@ -191,28 +195,19 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) { return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err) } - if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil { - return "", fmt.Errorf("persist bitwarden session: %w", err) - } - // Save to shared file so other MCP processes can reuse this session - // without needing to call bw unlock themselves. if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, session); err != nil { - return "", fmt.Errorf("persist shared bitwarden session: %w", err) + return "", fmt.Errorf("persist bitwarden session: %w", err) } return session, nil } // loadAnyBitwardenSession looks for an existing valid session in: the process -// environment, the service-specific file, and the shared file written by any -// MCP login. Returns the first non-empty session found. -func loadAnyBitwardenSession(serviceName string) string { +// environment, then the shared file written by any MCP login. +func loadAnyBitwardenSession() string { if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" { return strings.TrimSpace(session) } - if session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}); err == nil { - return session - } if session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}); err == nil { return session } diff --git a/secretstore/bitwarden_session_test.go b/secretstore/bitwarden_session_test.go index 8a8e54d..4c0d377 100644 --- a/secretstore/bitwarden_session_test.go +++ b/secretstore/bitwarden_session_test.go @@ -60,12 +60,12 @@ func TestLoginBitwardenRunsInteractiveFlowAndPersistsSession(t *testing.T) { t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1]) } - persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}) + persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}) if err != nil { - t.Fatalf("LoadBitwardenSession returned error: %v", err) + t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err) } if persisted != "persisted-session" { - t.Fatalf("persisted session = %q, want persisted-session", persisted) + t.Fatalf("shared persisted session = %q, want persisted-session", persisted) } } diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index 705a467..bf4d451 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -761,7 +761,7 @@ func TestBitwardenStorePicksUpSessionRotatedByAnotherProcess(t *testing.T) { t.Fatalf("Open returned error: %v", err) } - if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "new-session"); err != nil { + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "new-session"); err != nil { t.Fatalf("SaveBitwardenSession returned error: %v", err) } @@ -800,7 +800,7 @@ func TestBitwardenStorePicksUpSessionFromFileWhenEnvIsEmpty(t *testing.T) { t.Fatalf("Open returned error: %v", err) } - if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "file-session"); err != nil { + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "file-session"); err != nil { t.Fatalf("SaveBitwardenSession returned error: %v", err) } -- 2.45.2 From 846894c1a7e9eac047f92a0877287817fed94821 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 13:57:24 +0200 Subject: [PATCH 77/79] =?UTF-8?q?docs(changelog):=20mettre=20=C3=A0=20jour?= =?UTF-8?q?=20la=20description=20du=20fix=20Bitwarden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f407c64..384c5af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### Corrections -- **Secretstore — isolation des sessions Bitwarden entre MCPs** : `bw unlock` n'est plus appelé si le vault est déjà ouvert et qu'une session valide existe (env, fichier service, ou fichier partagé). Le login écrit désormais dans un fichier partagé (`~/.config/mcp-framework/bw-session`) que les autres MCPs lisent en priorité, évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit aussi ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage. +- **Secretstore — isolation des sessions Bitwarden entre MCPs** : `bw unlock` n'est plus appelé si le vault est déjà ouvert et qu'une session valide existe (env ou fichier partagé). Le login écrit uniquement dans `~/.config/mcp-framework/bw-session` — fichier commun à tous les MCPs — évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage. ### Changements cassants -- 2.45.2 From 7c999d2abad37a7b98358464aeea2b26dd2e03cd Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 13 May 2026 14:00:05 +0200 Subject: [PATCH 78/79] =?UTF-8?q?docs(changelog):=20d=C3=A9placer=20le=20f?= =?UTF-8?q?ix=20Bitwarden=20dans=20Unreleased,=20restaurer=20v1.12.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 384c5af..e946260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Corrections + +- **Secretstore — isolation des sessions Bitwarden entre MCPs** : `bw unlock` n'est plus appelé si le vault est déjà ouvert et qu'une session valide existe (env ou fichier partagé). Le login écrit uniquement dans `~/.config/mcp-framework/bw-session` — fichier commun à tous les MCPs — évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage. + ## [v1.12.0] — 2026-05-13 ### Nouvelles fonctionnalités @@ -10,10 +14,6 @@ - **Bootstrap — `StandardConfigTestHandler`** : handler de config test standard sans `ManifestCheck`. Accepte `ConfigCheck`, `OpenStore`, `ConnectivityCheck` et `ExtraChecks`. - **CLI — `ManifestCheck` opt-in dans `RunDoctor`** : le check de manifeste n'est inclus que si `ManifestDir` est fourni, supprimant une contrainte runtime inutile. -### Corrections - -- **Secretstore — isolation des sessions Bitwarden entre MCPs** : `bw unlock` n'est plus appelé si le vault est déjà ouvert et qu'une session valide existe (env ou fichier partagé). Le login écrit uniquement dans `~/.config/mcp-framework/bw-session` — fichier commun à tous les MCPs — évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage. - ### Changements cassants - **Bootstrap — `DefaultLoginHandler` renommé en `BitwardenLoginHandler`** : le nom précédent suggérait à tort que le handler s'applique à tous les MCPs. Les projets sans backend Bitwarden ne définissent pas de hook Login — la commande est masquée automatiquement par `autoDisabledCommands`. -- 2.45.2 From 0e0e1f6de65c461549e3e67e3bb697405b5dd031 Mon Sep 17 00:00:00 2001 From: CI Date: Wed, 13 May 2026 12:01:26 +0000 Subject: [PATCH 79/79] chore(changelog): release v1.13.0 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e946260..ab0f09a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [v1.13.0] — 2026-05-13 + ### Corrections - **Secretstore — isolation des sessions Bitwarden entre MCPs** : `bw unlock` n'est plus appelé si le vault est déjà ouvert et qu'une session valide existe (env ou fichier partagé). Le login écrit uniquement dans `~/.config/mcp-framework/bw-session` — fichier commun à tous les MCPs — évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage. @@ -214,6 +216,7 @@ Première release stable du framework. --- +[v1.13.0]: http://forgejo:3000/AI/mcp-framework/releases/tag/v1.13.0 [v1.12.0]: http://forgejo:3000/AI/mcp-framework/releases/tag/v1.12.0 [v1.11.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.11.0 [v1.10.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.10.0 -- 2.45.2