15 KiB
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
go get gitea.lclr.dev/AI/mcp-framework
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 undoctor.config: lecture/écriture atomique d'une config JSON versionnée dansos.UserConfigDir().manifest: lecture demcp.tomlà la racine du projet, conversion versupdate.ReleaseSourceet exposition de métadonnées pourbootstrap/scaffolding.secretstore: lecture/écriture de secrets dans le wallet natif.update: téléchargement et remplacement du binaire courant depuis un endpoint de release.
Utilisation type
Le flux typique côté application est :
- Déclarer les sous-commandes communes via
bootstrap(optionnel). - Résoudre le profil actif avec
cli. - Charger la config versionnée avec
config. - Lire les secrets avec
secretstore. - Charger
mcp.tomlavecmanifest. - Exécuter l'auto-update avec
updatesi nécessaire. - Exécuter
doctorpour 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 :
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)
},
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).
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 :
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 :
-
binary_name: nom du binaire (utilisable par le bootstrap/scaffolding). -
docs_url: URL de documentation projet. -
[update]: source de release consommée parupdate. -
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 :
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
Config JSON
Le package config stocke une structure générique par profil dans un JSON privé
pour l'utilisateur courant.
Exemple :
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,
LoadetLoadDefaultretournent une config vide par défaut NewStoreWithOptionspermet 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é :
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 siLookupEnvest fournikwallet-only: impose KWallet et retourne une erreur explicite si KWallet n'est pas disponiblekeyring-any: impose l'utilisation d'un backend keyring disponibleenv-only: lecture seule depuis les variables d'environnement
Backends keyring typiques :
- macOS : Keychain
- Linux : Secret Service ou KWallet selon l'environnement
- Windows : Credential Manager
Exemple :
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 :
store, err := secretstore.Open(secretstore.Options{
ServiceName: "email-mcp",
BackendPolicy: secretstore.BackendKWalletOnly,
})
Pour stocker un secret structuré en JSON :
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 :
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 :
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 :
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 :
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
ConfigCheck: cli.NewConfigCheck(store),
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) {
return secretstore.Open(secretstore.Options{
ServiceName: "my-mcp",
})
}),
RequiredSecrets: []cli.DoctorSecret{
{Name: "api-token", Label: "API token"},
},
SecretStoreFactory: func() (secretstore.Store, error) {
return secretstore.Open(secretstore.Options{
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)
}
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 format attendu pour la réponse latest release est actuellement :
{
"tag_name": "v1.2.3",
"assets": {
"links": [
{
"name": "my-mcp-linux-amd64",
"url": "https://example.com/downloads/my-mcp-linux-amd64"
}
]
}
}
Exemple :
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
}
Contraintes actuelles :
- le
latest_release_urldoit être renseigné explicitement - les assets supportés sont
darwin/amd64,darwin/arm64,linux/amd64etwindows/amd64 - le remplacement du binaire n'est pas supporté sur Windows
- le nom de l'asset est dérivé de
BinaryName,GOOSetGOARCH
Exemple Minimal
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