docs: reorganize README and split detailed documentation
This commit is contained in:
parent
fbff660bcc
commit
f0e2e9304b
13 changed files with 674 additions and 630 deletions
654
README.md
654
README.md
|
|
@ -1,25 +1,23 @@
|
||||||
# mcp-framework
|
# mcp-framework
|
||||||
|
|
||||||
Bibliothèque Go pour construire des binaires MCP avec :
|
`mcp-framework` est une bibliothèque Go pour construire des binaires MCP robustes, sans imposer un runtime lourd.
|
||||||
|
|
||||||
- résolution de profils CLI
|
## Le principal à savoir
|
||||||
- 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,
|
- Le framework fournit des briques réutilisables : config locale, secrets, résolution CLI, manifeste projet, et auto-update.
|
||||||
pas une application MCP complète.
|
- Il peut être utilisé de manière modulaire (package par package) ou avec un bootstrap CLI prêt à l'emploi.
|
||||||
|
- Il inclut un générateur de squelette (`mcp-framework scaffold init`) pour démarrer un nouveau binaire MCP rapidement.
|
||||||
|
- Toute la documentation détaillée est maintenant organisée dans `docs/` par grandes parties.
|
||||||
|
|
||||||
## Installation
|
## Démarrage rapide
|
||||||
|
|
||||||
|
Installer le framework dans un projet Go existant :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gitea.lclr.dev/AI/mcp-framework
|
go get gitea.lclr.dev/AI/mcp-framework
|
||||||
```
|
```
|
||||||
|
|
||||||
## CLI de scaffold
|
Initialiser un nouveau projet MCP depuis un dossier vide :
|
||||||
|
|
||||||
Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go :
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
||||||
|
|
@ -38,621 +36,17 @@ go mod tidy
|
||||||
go run ./cmd/my-mcp help
|
go run ./cmd/my-mcp help
|
||||||
```
|
```
|
||||||
|
|
||||||
## Packages
|
## Documentation
|
||||||
|
|
||||||
- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites.
|
- Vue d'ensemble : [docs/README.md](docs/README.md)
|
||||||
- `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`.
|
- Installation et usage type : [docs/getting-started.md](docs/getting-started.md)
|
||||||
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
|
- Packages : [docs/packages.md](docs/packages.md)
|
||||||
- `manifest` : lecture de `mcp.toml` à la racine du projet, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding.
|
- Bootstrap CLI : [docs/bootstrap-cli.md](docs/bootstrap-cli.md)
|
||||||
- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage).
|
- Manifeste `mcp.toml` : [docs/manifest.md](docs/manifest.md)
|
||||||
- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`.
|
- Scaffolding : [docs/scaffolding.md](docs/scaffolding.md)
|
||||||
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
|
- Config JSON : [docs/config.md](docs/config.md)
|
||||||
|
- Secrets : [docs/secrets.md](docs/secrets.md)
|
||||||
## Utilisation type
|
- Helpers CLI : [docs/cli-helpers.md](docs/cli-helpers.md)
|
||||||
|
- Auto-update : [docs/auto-update.md](docs/auto-update.md)
|
||||||
Le flux typique côté application est :
|
- Exemple minimal : [docs/minimal-example.md](docs/minimal-example.md)
|
||||||
|
- Limites actuelles : [docs/limitations.md](docs/limitations.md)
|
||||||
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 = true
|
|
||||||
signature_asset_name = "{asset}.sig"
|
|
||||||
signature_required = false
|
|
||||||
signature_public_key_env_names = ["MY_MCP_RELEASE_ED25519_PUBLIC_KEY"]
|
|
||||||
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.
|
|
||||||
- `signature_asset_name` : nom d'asset signature Ed25519 (détachée), avec placeholder optionnel `{asset}`.
|
|
||||||
- `signature_required` : si `true`, l'update échoue si la signature ou la clé publique manquent, ou si la signature est invalide.
|
|
||||||
- `signature_public_key` : clé publique Ed25519 (hex ou base64) utilisée pour vérifier la signature.
|
|
||||||
- `signature_public_key_env_names` : variables d'environnement candidates contenant la clé publique Ed25519.
|
|
||||||
- `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/<binary>/main.go`, `internal/app/app.go`, `mcp.toml`)
|
|
||||||
- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP)
|
|
||||||
- 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 `<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
|
|
||||||
|
|
|
||||||
17
docs/README.md
Normal file
17
docs/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Documentation mcp-framework
|
||||||
|
|
||||||
|
Cette documentation est organisée par grandes parties pour séparer la vue d'ensemble des détails d'implémentation.
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
- [Installation et utilisation type](getting-started.md)
|
||||||
|
- [Packages](packages.md)
|
||||||
|
- [Bootstrap CLI](bootstrap-cli.md)
|
||||||
|
- [Manifeste `mcp.toml`](manifest.md)
|
||||||
|
- [Scaffolding](scaffolding.md)
|
||||||
|
- [Config JSON](config.md)
|
||||||
|
- [Secrets](secrets.md)
|
||||||
|
- [Helpers CLI](cli-helpers.md)
|
||||||
|
- [Auto-update](auto-update.md)
|
||||||
|
- [Exemple minimal](minimal-example.md)
|
||||||
|
- [Limites actuelles](limitations.md)
|
||||||
51
docs/auto-update.md
Normal file
51
docs/auto-update.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# 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 `<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
|
||||||
45
docs/bootstrap-cli.md
Normal file
45
docs/bootstrap-cli.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# 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.
|
||||||
187
docs/cli-helpers.md
Normal file
187
docs/cli-helpers.md
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
_ = token
|
||||||
|
```
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = baseURL
|
||||||
|
_ = enabled
|
||||||
|
_ = scopes
|
||||||
|
```
|
||||||
|
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
64
docs/config.md
Normal file
64
docs/config.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# 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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
40
docs/getting-started.md
Normal file
40
docs/getting-started.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Installation et utilisation type
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
3
docs/limitations.md
Normal file
3
docs/limitations.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Limites actuelles
|
||||||
|
|
||||||
|
- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes
|
||||||
83
docs/manifest.md
Normal file
83
docs/manifest.md
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# 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 = true
|
||||||
|
signature_asset_name = "{asset}.sig"
|
||||||
|
signature_required = false
|
||||||
|
signature_public_key_env_names = ["MY_MCP_RELEASE_ED25519_PUBLIC_KEY"]
|
||||||
|
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.
|
||||||
|
- `signature_asset_name` : nom d'asset signature Ed25519 (détachée), avec placeholder optionnel `{asset}`.
|
||||||
|
- `signature_required` : si `true`, l'update échoue si la signature ou la clé publique manquent, ou si la signature est invalide.
|
||||||
|
- `signature_public_key` : clé publique Ed25519 (hex ou base64) utilisée pour vérifier la signature.
|
||||||
|
- `signature_public_key_env_names` : variables d'environnement candidates contenant la clé publique Ed25519.
|
||||||
|
- `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
|
||||||
|
_ = source
|
||||||
|
```
|
||||||
36
docs/minimal-example.md
Normal file
36
docs/minimal-example.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
```
|
||||||
9
docs/packages.md
Normal file
9
docs/packages.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# 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`, `install.sh` wizard, 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.
|
||||||
26
docs/scaffolding.md
Normal file
26
docs/scaffolding.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# 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.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP)
|
||||||
|
- 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))
|
||||||
|
```
|
||||||
89
docs/secrets.md
Normal file
89
docs/secrets.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
_ = creds
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
Loading…
Reference in a new issue