18 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
CLI de scaffold
Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go :
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é :
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 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.scaffold: génération d'un squelette de projet MCP (arborescence,main.go,mcp.toml,install.shwizard, wiring de base et README de démarrage).secretstore: lecture/écriture de secrets dans le wallet natif, avec helper runtimeOpenFromManifest.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,
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 :
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 parupdate. -
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/repoougroup/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: sitrue, 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 :
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/<binary>/main.go,internal/app/app.go,mcp.toml) - script
install.shprêt à publier (curl .../install.sh | bash) avec wizard TUI (setup, apply Claude/Codex, JSON MCP) - wiring initial
bootstrap + config + secretstore + update README.mdde démarrage
Exemple :
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 :
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
Ouverture recommandée depuis la policy du manifeste runtime (mcp.toml résolu
près de l'exécutable) :
store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
ServiceName: "my-mcp",
LookupEnv: os.LookupEnv,
})
if err != nil {
return err
}
Exemple bas niveau :
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 :
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 :
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 :
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[]avecbrowser_download_url(GitHub et Gitea API)
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
}
Comportement :
- le nom de l'asset est configurable (
asset_name_template) et supporte tout coupleGOOS/GOARCH - si un asset
<asset>.sha256(ouchecksum_asset_name) existe, le binaire téléchargé est vérifié avant remplacement - un hook
ValidateDownloadedpermet d'ajouter une validation custom (signature, scan, etc.) - sur Windows, le remplacement in-place n'est pas fait par défaut ; fournir
Options.ReplaceExecutablepour une stratégie dédiée
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