No description
Find a file
2026-04-14 18:02:07 +02:00
.gitea/workflows fix(ci): build changelog from last stable release tag 2026-04-14 10:28:15 +02:00
bootstrap feat(bootstrap): expose doctor alias in help and options 2026-04-14 17:51:28 +02:00
cli feat(cli): add reusable doctor check for resolved profile fields 2026-04-14 16:53:26 +02:00
cmd/mcp-framework feat: add unified build command driven by mcp.toml 2026-04-14 18:02:07 +02:00
config feat: add versioned config migrations 2026-04-13 17:28:47 +02:00
manifest feat: add unified build command driven by mcp.toml 2026-04-14 18:02:07 +02:00
scaffold feat: add unified build command driven by mcp.toml 2026-04-14 18:02:07 +02:00
secretstore feat(secretstore): add runtime manifest helper for backend opening 2026-04-14 16:40:50 +02:00
update feat(update): add forge drivers and checksum validation hooks 2026-04-14 15:05:52 +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: add unified build command driven by mcp.toml 2026-04-14 18:02:07 +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

CLI de scaffold

Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go :

go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
mcp-framework scaffold init \
  --target ./my-mcp \
  --module example.com/my-mcp \
  --binary my-mcp \
  --profiles dev,prod

Puis dans le projet généré :

cd my-mcp
go mod tidy
go run ./cmd/my-mcp help

CLI de build unifiée

Le binaire mcp-framework expose aussi une commande de build standardisée pour les projets consommateurs :

go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build

Par défaut la commande :

  • lit mcp.toml (en remontant les répertoires parents)
  • récupère binary_name
  • build ./cmd/<binary_name>
  • produit build/<binary>-<goos>-<goarch>{.exe}
  • injecte la version via -X main.version=<version> (VERSION env, sinon git describe, sinon dev)

Options principales :

  • --manifest-dir
  • --binary
  • --package
  • --build-dir
  • --goos
  • --goarch
  • --version
  • --version-var
  • --ldflag (répétable)

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, 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.

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,
		EnableDoctorAlias: true, // expose `doctor` comme alias de `config test`
		AliasDescriptions: map[string]string{
			"doctor": "Diagnostiquer la configuration locale.",
		},
		Hooks: bootstrap.Hooks{
			Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
				return runSetup(ctx, inv.Args)
			},
			MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
				return runMCP(ctx, inv.Args)
			},
			ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error {
				return runConfigShow(ctx, inv.Args)
			},
			ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error {
				return runConfigTest(ctx, inv.Args)
			},
			Update: func(ctx context.Context, inv bootstrap.Invocation) error {
				return runUpdate(ctx, inv.Args)
			},
		},
	})
	if err != nil {
		log.Fatal(err)
	}
}

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

Sans arguments, bootstrap.Run affiche l'aide globale (pas de lancement implicite de mcp). La commande config impose une sous-commande (show, test, et optionnellement delete). Les alias déclarés (ou EnableDoctorAlias) sont affichés dans l'aide globale.

Manifeste mcp.toml

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

Exemple minimal :

binary_name = "my-mcp"
docs_url = "https://docs.example.com/my-mcp"

[update]
source_name = "Gitea releases"
driver = "gitea"
repository = "org/repo"
base_url = "https://gitea.example.com"
asset_name_template = "{binary}-{os}-{arch}{ext}"
checksum_asset_name = "{asset}.sha256"
checksum_required = false
token_header = "Authorization"
token_prefix = "token"
token_env_names = ["GITEA_TOKEN"]

[environment]
known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"]

[secret_store]
backend_policy = "auto"

[profiles]
default = "prod"
known = ["dev", "staging", "prod"]

[bootstrap]
description = "Client MCP interne"

Champs supportés :

  • binary_name : nom du binaire (utilisable par le bootstrap/scaffolding).

  • docs_url : URL de documentation projet.

  • [update] : source de release consommée par update.

  • [build] : paramètres de build pour mcp-framework build.

  • 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.

  • 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.

  • [build].main_package : package main à builder (ex: ./cmd/my-mcp).

  • [build].output_dir : répertoire de sortie des artefacts (défaut build).

  • [build].version_var : variable Go ciblée par -X pour injecter la version (main.version, gitlab.../internal/app.Version, etc.).

  • [environment].known : variables d'environnement connues du projet.

  • [secret_store].backend_policy : politique de secret store (auto, kwallet-only, keyring-any, env-only).

  • [profiles].default : profil recommandé par défaut.

  • [profiles].known : profils connus du projet.

  • [bootstrap].description : description CLI utilisée par le bootstrap.

Toutes ces sections (hors [update] selon les besoins de l'application) sont optionnelles.

Exemple de chargement :

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

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

Scaffolding

Le package scaffold génère un point de départ cohérent pour un nouveau binaire MCP :

  • arborescence recommandée (cmd/<binary>/main.go, internal/app/app.go, mcp.toml)
  • wiring initial bootstrap + config + secretstore + update
  • README.md de démarrage

Exemple :

result, err := scaffold.Generate(scaffold.Options{
	TargetDir:      "./my-mcp",
	ModulePath:     "gitea.lclr.dev/AI/my-mcp",
	BinaryName:     "my-mcp",
	Description:    "Client MCP interne",
	DefaultProfile: "prod",
	Profiles:       []string{"dev", "prod"},
})
if err != nil {
	return err
}

fmt.Printf("Scaffold generated in %s (%d files)\n", result.Root, len(result.Files))

Config JSON

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

Exemple :

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

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

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

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

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

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

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

Notes :

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

Ouverture recommandée depuis la policy du manifeste runtime (mcp.toml résolu près de l'exécutable) :

store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
	ServiceName: "my-mcp",
	LookupEnv:   os.LookupEnv,
})
if err != nil {
	return err
}

Exemple bas niveau :

store, err := secretstore.Open(secretstore.Options{
	ServiceName:   "my-mcp",
	BackendPolicy: secretstore.BackendAuto,
})
if err != nil {
	return err
}

if err := store.SetSecret("api-token", "My MCP API token", token); err != nil {
	return err
}

token, err = store.GetSecret("api-token")
switch {
case err == nil:
	// secret found
case errors.Is(err, secretstore.ErrNotFound):
	// first run
default:
	return err
}

Pour imposer KWallet sur Linux :

store, err := secretstore.Open(secretstore.Options{
	ServiceName:   "email-mcp",
	BackendPolicy: secretstore.BackendKWalletOnly,
})

Pour stocker un secret structuré en JSON :

type Credentials struct {
	Host     string `json:"host"`
	Username string `json:"username"`
	Password string `json:"password"`
}

err = secretstore.SetJSON(store, "imap-credentials", "IMAP credentials", Credentials{
	Host:     "imap.example.com",
	Username: "alice",
	Password: token,
})
if err != nil {
	return err
}

creds, err := secretstore.GetJSON[Credentials](store, "imap-credentials")
if err != nil {
	return err
}

En mode env-only, GetSecret("API_TOKEN") lit la variable d'environnement API_TOKEN. Les opérations d'écriture et de suppression retournent secretstore.ErrReadOnly.

Helpers CLI

cli fournit des helpers simples pour les assistants interactifs :

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

baseURL, err := cli.PromptLine(reader, os.Stdout, "Base URL", profile.BaseURL)
if err != nil {
	return err
}
if err := cli.ValidateBaseURL(baseURL); err != nil {
	return err
}

token, err := cli.PromptSecret(os.Stdin, os.Stdout, "API token", hasStoredSecret, storedToken)
if err != nil {
	return err
}

Pour décrire un setup complet sans réécrire la boucle interactive :

result, err := cli.RunSetup(cli.SetupOptions{
	Stdin:  os.Stdin,
	Stdout: os.Stdout,
	Fields: []cli.SetupField{
		{
			Name:     "base_url",
			Label:    "Base URL",
			Type:     cli.SetupFieldURL,
			Required: true,
		},
		{
			Name:           "api_token",
			Label:          "API token",
			Type:           cli.SetupFieldSecret,
			Required:       true,
			ExistingSecret: storedToken, // conserve la valeur existante si l'utilisateur laisse vide
		},
		{
			Name:    "enabled",
			Label:   "Enable integration",
			Type:    cli.SetupFieldBool,
			Default: "true",
		},
		{
			Name:      "scopes",
			Label:     "Scopes",
			Type:      cli.SetupFieldList,
			Default:   "read,write",
			Normalize: func(value string) string { return strings.TrimSpace(strings.ToLower(value)) },
		},
	},
})
if err != nil {
	return err
}

baseURL, _ := result.Get("base_url")
apiToken, _ := result.Get("api_token")
enabled, _ := result.Get("enabled")
scopes, _ := result.Get("scopes")

if apiToken.KeptStoredSecret {
	fmt.Println("Stored token kept.")
}

Chaque champ peut déclarer ses propres hooks de validation (Validate, ValidateBool, ValidateList). Les validations sont appliquées de manière cohérente en TTY et en stdin non interactif.

Pour standardiser la résolution flag > env > config > secret avec provenance :

store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
	ServiceName: "my-mcp",
})
if err != nil {
	return err
}

lookup := cli.ResolveLookup(cli.ResolveLookupOptions{
	Flag:   cli.MapLookup(flagValues),
	Env:    cli.EnvLookup(os.LookupEnv),
	Config: cli.ConfigMap(configValues),
	Secret: cli.SecretStore(store), // ErrNotFound => valeur absente, pas une erreur
})

resolution, err := cli.ResolveFields(cli.ResolveOptions{
	Fields: []cli.FieldSpec{
		{Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"},
		{Name: "api_token", Required: true, EnvKey: "MY_MCP_API_TOKEN", SecretKey: "my-mcp-api-token"},
		{Name: "timeout", DefaultValue: "30s"},
	},
	Lookup: lookup,
})
if err != nil {
	return err
}

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

ResolveOptions.Order permet de changer la priorité globale si nécessaire, et FieldSpec.Sources permet de définir un ordre spécifique pour un champ.

Le package fournit aussi un socle réutilisable pour une commande doctor. L'application cliente choisit comment exposer la commande (flag, cobra, etc.), mais peut réutiliser les checks communs et ajouter ses propres hooks :

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

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

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

Pour éviter des checks custom répétitifs sur les profils, doctor expose aussi un helper basé sur FieldSpec :

report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
	ExtraChecks: []cli.DoctorCheck{
		cli.RequiredResolvedFieldsCheck(cli.ResolveOptions{
			Fields: []cli.FieldSpec{
				{Name: "base_url", Required: true, EnvKey: "MY_MCP_BASE_URL"},
				{
					Name:      "api_token",
					Required:  true,
					EnvKey:    "MY_MCP_API_TOKEN",
					SecretKey: "my-mcp-api-token",
					Sources:   []cli.ValueSource{cli.SourceEnv, cli.SourceSecret},
				},
			},
			Lookup: cli.ResolveLookup(cli.ResolveLookupOptions{
				Env:    cli.EnvLookup(os.LookupEnv),
				Config: cli.ConfigMap(configValues),
				Secret: cli.SecretStore(secretStore), // provenance explicite: env ou secret
			}),
		}),
	},
})

En cas d'échec de résolution, tu peux aussi réutiliser le formatteur cli.FormatResolveFieldsError(err) dans un check custom pour garder des messages homogènes.

Auto-Update

Le package update supporte les drivers gitea, gitlab et github. Si latest_release_url est vide, l'URL latest est déduite depuis driver + repository (+ base_url).

Le parseur de release supporte :

  • format assets.links (Gitea/GitLab)
  • format assets[] avec browser_download_url (GitHub et Gitea API)

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

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

Exemple :

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

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

Comportement :

  • le nom de l'asset est configurable (asset_name_template) et supporte tout 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

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