No description
Find a file
2026-04-14 10:21:54 +02:00
.gitea/workflows fix(ci): mark prerelease tags as prereleases 2026-04-13 22:04:59 +02:00
bootstrap feat: add optional CLI bootstrap package 2026-04-14 08:33:17 +02:00
cli feat(cli): add declarative typed setup engine 2026-04-14 09:17:45 +02:00
config feat: add versioned config migrations 2026-04-13 17:28:47 +02:00
manifest feat: add toml manifest loader for mcp projects 2026-04-13 15:52:00 +02:00
secretstore feat: support structured secrets and backend policies 2026-04-13 16:47:22 +02:00
update refactor: decouple update package from forge-specific gitlab config 2026-04-13 15:46:28 +02:00
.gitignore update .gitignore 2026-04-13 15:47:20 +02:00
AGENTS.md docs(agents): enforce enhancement branch naming format 2026-04-14 08:31:08 +02:00
go.mod feat: add toml manifest loader for mcp projects 2026-04-13 15:52:00 +02:00
go.sum feat: add toml manifest loader for mcp projects 2026-04-13 15:52:00 +02:00
README.md feat(cli): add declarative typed setup engine 2026-04-14 09:17:45 +02:00

mcp-framework

Bibliothèque Go pour construire des binaires MCP avec :

  • résolution de profils CLI
  • stockage JSON de configuration dans os.UserConfigDir()
  • stockage de secrets dans le wallet natif selon l'OS
  • lecture d'un manifeste mcp.toml à la racine du projet
  • pipeline d'auto-update via endpoint de release configurable

Le framework est volontairement petit. Il fournit des briques réutilisables, pas une application MCP complète.

Installation

go get gitea.lclr.dev/AI/mcp-framework

Packages

  • bootstrap : couche CLI optionnelle avec sous-commandes communes (setup, mcp, config, update, version) et hooks métier explicites.
  • cli : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter 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 :

func main() {
	err := bootstrap.Run(context.Background(), bootstrap.Options{
		BinaryName:  "my-mcp",
		Description: "Client MCP",
		Version:     version,
		Hooks: bootstrap.Hooks{
			Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
				return runSetup(ctx, inv.Args)
			},
			MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
				return runMCP(ctx, inv.Args)
			},
			Config: func(ctx context.Context, inv bootstrap.Invocation) error {
				return runConfig(ctx, inv.Args)
			},
			Update: func(ctx context.Context, inv bootstrap.Invocation) error {
				return runUpdate(ctx, inv.Args)
			},
		},
	})
	if err != nil {
		log.Fatal(err)
	}
}

Si Hooks.Version n'est pas fourni, la sous-commande version affiche automatiquement Options.Version.

Manifeste mcp.toml

Le package manifest cherche automatiquement mcp.toml dans le répertoire courant puis remonte les répertoires parents jusqu'à trouver le fichier.

Exemple minimal :

[update]
source_name = "Gitea releases"
base_url = "https://gitea.example.com"
latest_release_url = "https://gitea.example.com/api/v1/repos/org/repo/releases/latest"
token_header = "Authorization"
token_env_names = ["GITEA_TOKEN"]

Champs supportés dans [update] :

  • source_name : nom humain de la source de release, utilisé dans certains messages d'erreur.
  • base_url : base de la forge ou du service de release.
  • latest_release_url : URL complète qui retourne la release la plus récente.
  • token_header : header HTTP à utiliser pour l'authentification.
  • token_env_names : liste de variables d'environnement candidates pour retrouver le token.

Exemple de chargement :

file, path, err := manifest.LoadDefault(".")
if err != nil {
	return err
}

fmt.Printf("manifest loaded from %s\n", path)
source := file.Update.ReleaseSource()

Config JSON

Le package config stocke une structure générique par profil dans un JSON privé pour l'utilisateur courant.

Exemple :

type Profile struct {
	BaseURL string `json:"base_url"`
	APIKey  string `json:"api_key"`
}

store := config.NewStore[Profile]("my-mcp")

cfg, path, err := store.LoadDefault()
if err != nil {
	return err
}

profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
profile := cfg.Profiles[profileName]

profile.BaseURL = "https://api.example.com"
cfg.CurrentProfile = profileName
cfg.Profiles[profileName] = profile

_, err = store.SaveDefault(cfg)
if err != nil {
	return err
}

fmt.Printf("config saved to %s\n", path)

Notes :

  • le fichier est créé avec des permissions 0600
  • le répertoire parent est forcé en 0700
  • l'écriture est atomique via un fichier temporaire puis rename
  • si le fichier n'existe pas, 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é :

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 :

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 :

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 :

report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
	ConfigCheck:      cli.NewConfigCheck(store),
	SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) {
		return secretstore.Open(secretstore.Options{
			ServiceName: "my-mcp",
		})
	}),
	RequiredSecrets: []cli.DoctorSecret{
		{Name: "api-token", Label: "API token"},
	},
	SecretStoreFactory: func() (secretstore.Store, error) {
		return secretstore.Open(secretstore.Options{
			ServiceName: "my-mcp",
		})
	},
	ManifestDir: ".",
	ConnectivityCheck: func(context.Context) cli.DoctorResult {
		if err := pingBackend(); err != nil {
			return cli.DoctorResult{
				Name:    "connectivity",
				Status:  cli.DoctorStatusFail,
				Summary: "backend is unreachable",
				Detail:  err.Error(),
			}
		}
		return cli.DoctorResult{
			Name:    "connectivity",
			Status:  cli.DoctorStatusOK,
			Summary: "backend is reachable",
		}
	},
})

if err := cli.RenderDoctorReport(os.Stdout, report); err != nil {
	return err
}

if report.HasFailures() {
	os.Exit(1)
}

Auto-Update

Le package update ne déduit pas la forge ni l'authentification. L'application cliente fournit l'URL de release, le header d'auth éventuel et, si besoin, les variables d'environnement à consulter.

Le format attendu pour la réponse latest release est actuellement :

{
  "tag_name": "v1.2.3",
  "assets": {
    "links": [
      {
        "name": "my-mcp-linux-amd64",
        "url": "https://example.com/downloads/my-mcp-linux-amd64"
      }
    ]
  }
}

Exemple :

file, _, err := manifest.LoadDefault(".")
if err != nil {
	return err
}

err = update.Run(ctx, update.Options{
	CurrentVersion: version,
	BinaryName:     "my-mcp",
	ReleaseSource:  file.Update.ReleaseSource(),
	Stdout:         os.Stdout,
})
if err != nil {
	return err
}

Contraintes actuelles :

  • le latest_release_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

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