# mcp-framework Bibliothèque Go pour construire des binaires MCP avec : - 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 framework est volontairement petit. Il fournit des briques réutilisables, pas une application MCP complète. ## 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 ``` ## 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`, 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 = false 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. - `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`) - 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