Merge pull request 'feat: ajouter une couche bootstrap CLI optionnelle' (#15) from feat/bootstrap-mcp-binary into release/v1.2

Reviewed-on: https://gitea.lclr.dev/AI/mcp-framework/pulls/15
This commit is contained in:
thibaud-lclr 2026-04-14 06:47:09 +00:00
commit c6c38d6019
4 changed files with 465 additions and 7 deletions

View file

@ -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/<issue_short_name>`.
Éviter de développer directement sur `main` quand le changement mérite une PR ou une validation fonctionnelle.
## Pull Requests

View file

@ -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

236
bootstrap/bootstrap.go Normal file
View file

@ -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 <command> [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 <command>\n", opts.BinaryName)
return err
}

182
bootstrap/bootstrap_test.go Normal file
View file

@ -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)
}
}