From f0e2e9304b21e82bbd26419bc7125e7e6c5e6251 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 14:06:28 +0200 Subject: [PATCH] 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`.