514 lines
13 KiB
Markdown
514 lines
13 KiB
Markdown
# 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
|
|
```
|
|
|
|
## 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 un `doctor`.
|
|
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
|
|
- `manifest` : lecture de `mcp.toml` à la racine du projet et conversion vers `update.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 :
|
|
|
|
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,
|
|
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 :
|
|
|
|
```toml
|
|
[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 :
|
|
|
|
```go
|
|
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 :
|
|
|
|
```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
|
|
|
|
Exemple :
|
|
|
|
```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
|
|
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 :
|
|
|
|
```go
|
|
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 :
|
|
|
|
```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
|
|
}
|
|
```
|
|
|
|
Contraintes actuelles :
|
|
|
|
- le `latest_release_url` doit être renseigné explicitement
|
|
- les assets supportés sont `darwin/amd64`, `darwin/arm64`, `linux/amd64` et `windows/amd64`
|
|
- le remplacement du binaire n'est pas supporté sur Windows
|
|
- le nom de l'asset est dérivé de `BinaryName`, `GOOS` et `GOARCH`
|
|
|
|
## 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
|
|
|
|
- le manifeste gère uniquement la section `[update]`
|
|
- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes
|