feat(manifest): extend mcp.toml metadata sections

This commit is contained in:
thibaud-lclr 2026-04-14 12:30:56 +02:00
parent 89246d1581
commit 17fe329ce9
3 changed files with 212 additions and 11 deletions

View file

@ -22,7 +22,7 @@ go get gitea.lclr.dev/AI/mcp-framework
- `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 et conversion vers `update.ReleaseSource`.
- `manifest` : lecture de `mcp.toml` à la racine du projet, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding.
- `secretstore` : lecture/écriture de secrets dans le wallet natif.
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
@ -89,21 +89,48 @@ 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"
base_url = "https://gitea.example.com"
latest_release_url = "https://gitea.example.com/api/v1/repos/org/repo/releases/latest"
token_header = "Authorization"
token_env_names = ["GITEA_TOKEN"]
[environment]
known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"]
[secret_store]
backend_policy = "auto"
[profiles]
default = "prod"
known = ["dev", "staging", "prod"]
[bootstrap]
description = "Client MCP interne"
```
Champs supportés dans `[update]` :
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.
- `base_url` : base de la forge ou du service de release.
- `latest_release_url` : URL complète qui retourne la release la plus récente.
- `token_header` : header HTTP à utiliser pour l'authentification.
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
- `[environment].known` : variables d'environnement connues du projet.
- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`).
- `[profiles].default` : profil recommandé par défaut.
- `[profiles].known` : profils connus du projet.
- `[bootstrap].description` : description CLI utilisée par le bootstrap.
Toutes ces sections (hors `[update]` selon les besoins de l'application) sont optionnelles.
Exemple de chargement :
@ -115,6 +142,10 @@ if err != nil {
fmt.Printf("manifest loaded from %s\n", path)
source := file.Update.ReleaseSource()
bootstrapInfo := file.BootstrapInfo()
scaffoldInfo := file.ScaffoldInfo()
_ = bootstrapInfo
_ = scaffoldInfo
```
## Config JSON
@ -516,5 +547,4 @@ func run(ctx context.Context, flagProfile string) error {
## Limites Actuelles
- le manifeste gère uniquement la section `[update]`
- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes

View file

@ -15,7 +15,13 @@ import (
const DefaultFile = "mcp.toml"
type File struct {
Update Update `toml:"update"`
BinaryName string `toml:"binary_name"`
DocsURL string `toml:"docs_url"`
Update Update `toml:"update"`
Environment Environment `toml:"environment"`
SecretStore SecretStore `toml:"secret_store"`
Profiles Profiles `toml:"profiles"`
Bootstrap Bootstrap `toml:"bootstrap"`
}
type Update struct {
@ -26,6 +32,40 @@ type Update struct {
TokenEnvNames []string `toml:"token_env_names"`
}
type Environment struct {
Known []string `toml:"known"`
}
type SecretStore struct {
BackendPolicy string `toml:"backend_policy"`
}
type Profiles struct {
Default string `toml:"default"`
Known []string `toml:"known"`
}
type Bootstrap struct {
Description string `toml:"description"`
}
type BootstrapMetadata struct {
BinaryName string
Description string
DocsURL string
DefaultProfile string
Profiles []string
}
type ScaffoldMetadata struct {
BinaryName string
DocsURL string
KnownEnvironmentVariables []string
SecretStorePolicy string
DefaultProfile string
Profiles []string
}
func Find(startDir string) (string, error) {
dir := strings.TrimSpace(startDir)
if dir == "" {
@ -92,7 +132,13 @@ func LoadDefault(startDir string) (File, string, error) {
}
func (f *File) normalize() {
f.BinaryName = strings.TrimSpace(f.BinaryName)
f.DocsURL = strings.TrimSpace(f.DocsURL)
f.Update.normalize()
f.Environment.normalize()
f.SecretStore.normalize()
f.Profiles.normalize()
f.Bootstrap.normalize()
}
func (u *Update) normalize() {
@ -100,14 +146,24 @@ func (u *Update) normalize() {
u.BaseURL = strings.TrimRight(strings.TrimSpace(u.BaseURL), "/")
u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL)
u.TokenHeader = strings.TrimSpace(u.TokenHeader)
u.TokenEnvNames = normalizeStringList(u.TokenEnvNames)
}
envNames := u.TokenEnvNames[:0]
for _, envName := range u.TokenEnvNames {
if trimmed := strings.TrimSpace(envName); trimmed != "" {
envNames = append(envNames, trimmed)
}
}
u.TokenEnvNames = envNames
func (e *Environment) normalize() {
e.Known = normalizeStringList(e.Known)
}
func (s *SecretStore) normalize() {
s.BackendPolicy = strings.TrimSpace(s.BackendPolicy)
}
func (p *Profiles) normalize() {
p.Default = strings.TrimSpace(p.Default)
p.Known = normalizeStringList(p.Known)
}
func (b *Bootstrap) normalize() {
b.Description = strings.TrimSpace(b.Description)
}
func (u Update) ReleaseSource() update.ReleaseSource {
@ -121,3 +177,38 @@ func (u Update) ReleaseSource() update.ReleaseSource {
TokenEnvNames: append([]string(nil), u.TokenEnvNames...),
}
}
func (f File) BootstrapInfo() BootstrapMetadata {
f.normalize()
return BootstrapMetadata{
BinaryName: f.BinaryName,
Description: f.Bootstrap.Description,
DocsURL: f.DocsURL,
DefaultProfile: f.Profiles.Default,
Profiles: append([]string(nil), f.Profiles.Known...),
}
}
func (f File) ScaffoldInfo() ScaffoldMetadata {
f.normalize()
return ScaffoldMetadata{
BinaryName: f.BinaryName,
DocsURL: f.DocsURL,
KnownEnvironmentVariables: append([]string(nil), f.Environment.Known...),
SecretStorePolicy: f.SecretStore.BackendPolicy,
DefaultProfile: f.Profiles.Default,
Profiles: append([]string(nil), f.Profiles.Known...),
}
}
func normalizeStringList(values []string) []string {
normalized := values[:0]
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
normalized = append(normalized, trimmed)
}
}
return normalized
}

View file

@ -4,6 +4,7 @@ import (
"errors"
"os"
"path/filepath"
"slices"
"testing"
)
@ -114,3 +115,82 @@ func TestLoadReturnsParseError(t *testing.T) {
t.Fatal("expected error")
}
}
func TestLoadParsesExtendedManifestMetadata(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, DefaultFile)
const content = `
binary_name = " my-mcp "
docs_url = " https://docs.example.com/mcp "
[update]
latest_release_url = "https://example.com/latest"
[environment]
known = [" MCP_PROFILE ", "", "MCP_TOKEN"]
[secret_store]
backend_policy = " auto "
[profiles]
default = " prod "
known = [" default ", "", "prod"]
[bootstrap]
description = " Client MCP interne "
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
file, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if file.BinaryName != "my-mcp" {
t.Fatalf("binary name = %q", file.BinaryName)
}
if file.DocsURL != "https://docs.example.com/mcp" {
t.Fatalf("docs URL = %q", file.DocsURL)
}
if !slices.Equal(file.Environment.Known, []string{"MCP_PROFILE", "MCP_TOKEN"}) {
t.Fatalf("environment known = %v", file.Environment.Known)
}
if file.SecretStore.BackendPolicy != "auto" {
t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy)
}
if file.Profiles.Default != "prod" {
t.Fatalf("default profile = %q", file.Profiles.Default)
}
if !slices.Equal(file.Profiles.Known, []string{"default", "prod"}) {
t.Fatalf("profiles known = %v", file.Profiles.Known)
}
if file.Bootstrap.Description != "Client MCP interne" {
t.Fatalf("bootstrap description = %q", file.Bootstrap.Description)
}
bootstrap := file.BootstrapInfo()
if bootstrap.BinaryName != "my-mcp" {
t.Fatalf("bootstrap binary name = %q", bootstrap.BinaryName)
}
if bootstrap.DocsURL != "https://docs.example.com/mcp" {
t.Fatalf("bootstrap docs URL = %q", bootstrap.DocsURL)
}
if bootstrap.DefaultProfile != "prod" {
t.Fatalf("bootstrap default profile = %q", bootstrap.DefaultProfile)
}
if !slices.Equal(bootstrap.Profiles, []string{"default", "prod"}) {
t.Fatalf("bootstrap profiles = %v", bootstrap.Profiles)
}
scaffold := file.ScaffoldInfo()
if scaffold.SecretStorePolicy != "auto" {
t.Fatalf("scaffold secret store policy = %q", scaffold.SecretStorePolicy)
}
if !slices.Equal(scaffold.KnownEnvironmentVariables, []string{"MCP_PROFILE", "MCP_TOKEN"}) {
t.Fatalf("scaffold known environment variables = %v", scaffold.KnownEnvironmentVariables)
}
}