11 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,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 et conversion versupdate.ReleaseSource.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)
},
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
Le package manifest cherche automatiquement mcp.toml dans le répertoire
courant puis remonte les répertoires parents jusqu'à trouver le fichier.
Exemple minimal :
[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"]
Champs supportés dans [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.
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()
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
}
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
- le manifeste gère uniquement la section
[update] - l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes