Merge pull request 'feat(manifest): étendre mcp.toml au-delà de [update]' (#18) from feat/manifest-extended-fields into release/v1.3
Reviewed-on: https://gitea.lclr.dev/AI/mcp-framework/pulls/18
This commit is contained in:
commit
42e1345962
5 changed files with 223 additions and 12 deletions
36
README.md
36
README.md
|
|
@ -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.
|
- `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`.
|
- `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()`.
|
- `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.
|
- `secretstore` : lecture/écriture de secrets dans le wallet natif.
|
||||||
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
|
- `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 :
|
Exemple minimal :
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
binary_name = "my-mcp"
|
||||||
|
docs_url = "https://docs.example.com/my-mcp"
|
||||||
|
|
||||||
[update]
|
[update]
|
||||||
source_name = "Gitea releases"
|
source_name = "Gitea releases"
|
||||||
base_url = "https://gitea.example.com"
|
base_url = "https://gitea.example.com"
|
||||||
latest_release_url = "https://gitea.example.com/api/v1/repos/org/repo/releases/latest"
|
latest_release_url = "https://gitea.example.com/api/v1/repos/org/repo/releases/latest"
|
||||||
token_header = "Authorization"
|
token_header = "Authorization"
|
||||||
token_env_names = ["GITEA_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 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.
|
- `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.
|
- `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.
|
- `latest_release_url` : URL complète qui retourne la release la plus récente.
|
||||||
- `token_header` : header HTTP à utiliser pour l'authentification.
|
- `token_header` : header HTTP à utiliser pour l'authentification.
|
||||||
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le 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 :
|
Exemple de chargement :
|
||||||
|
|
||||||
|
|
@ -115,6 +142,10 @@ if err != nil {
|
||||||
|
|
||||||
fmt.Printf("manifest loaded from %s\n", path)
|
fmt.Printf("manifest loaded from %s\n", path)
|
||||||
source := file.Update.ReleaseSource()
|
source := file.Update.ReleaseSource()
|
||||||
|
bootstrapInfo := file.BootstrapInfo()
|
||||||
|
scaffoldInfo := file.ScaffoldInfo()
|
||||||
|
_ = bootstrapInfo
|
||||||
|
_ = scaffoldInfo
|
||||||
```
|
```
|
||||||
|
|
||||||
## Config JSON
|
## Config JSON
|
||||||
|
|
@ -516,5 +547,4 @@ func run(ctx context.Context, flagProfile string) error {
|
||||||
|
|
||||||
## Limites Actuelles
|
## Limites Actuelles
|
||||||
|
|
||||||
- le manifeste gère uniquement la section `[update]`
|
|
||||||
- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes
|
- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,14 @@ func trimArgs(args []string) []string {
|
||||||
|
|
||||||
func runConfigCommand(ctx context.Context, opts Options, args []string) error {
|
func runConfigCommand(ctx context.Context, opts Options, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return fmt.Errorf("%w: %s requires one of: %s, %s", ErrSubcommandRequired, CommandConfig, ConfigSubcommandShow, ConfigSubcommandTest)
|
return fmt.Errorf(
|
||||||
|
"%w: %s requires one of: %s, %s, %s",
|
||||||
|
ErrSubcommandRequired,
|
||||||
|
CommandConfig,
|
||||||
|
ConfigSubcommandShow,
|
||||||
|
ConfigSubcommandTest,
|
||||||
|
ConfigSubcommandDelete,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
subcommand := strings.TrimSpace(args[0])
|
subcommand := strings.TrimSpace(args[0])
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) {
|
||||||
if !errors.Is(err, ErrSubcommandRequired) {
|
if !errors.Is(err, ErrSubcommandRequired) {
|
||||||
t.Fatalf("Run error = %v, want ErrSubcommandRequired", err)
|
t.Fatalf("Run error = %v, want ErrSubcommandRequired", err)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(err.Error(), "show, test, delete") {
|
||||||
|
t.Fatalf("Run error = %q, want mention of show, test, delete", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunPrintsVersionByDefault(t *testing.T) {
|
func TestRunPrintsVersionByDefault(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,13 @@ import (
|
||||||
const DefaultFile = "mcp.toml"
|
const DefaultFile = "mcp.toml"
|
||||||
|
|
||||||
type File struct {
|
type File struct {
|
||||||
|
BinaryName string `toml:"binary_name"`
|
||||||
|
DocsURL string `toml:"docs_url"`
|
||||||
Update Update `toml:"update"`
|
Update Update `toml:"update"`
|
||||||
|
Environment Environment `toml:"environment"`
|
||||||
|
SecretStore SecretStore `toml:"secret_store"`
|
||||||
|
Profiles Profiles `toml:"profiles"`
|
||||||
|
Bootstrap Bootstrap `toml:"bootstrap"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Update struct {
|
type Update struct {
|
||||||
|
|
@ -26,6 +32,40 @@ type Update struct {
|
||||||
TokenEnvNames []string `toml:"token_env_names"`
|
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) {
|
func Find(startDir string) (string, error) {
|
||||||
dir := strings.TrimSpace(startDir)
|
dir := strings.TrimSpace(startDir)
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
|
|
@ -92,7 +132,13 @@ func LoadDefault(startDir string) (File, string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *File) normalize() {
|
func (f *File) normalize() {
|
||||||
|
f.BinaryName = strings.TrimSpace(f.BinaryName)
|
||||||
|
f.DocsURL = strings.TrimSpace(f.DocsURL)
|
||||||
f.Update.normalize()
|
f.Update.normalize()
|
||||||
|
f.Environment.normalize()
|
||||||
|
f.SecretStore.normalize()
|
||||||
|
f.Profiles.normalize()
|
||||||
|
f.Bootstrap.normalize()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *Update) normalize() {
|
func (u *Update) normalize() {
|
||||||
|
|
@ -100,14 +146,24 @@ func (u *Update) normalize() {
|
||||||
u.BaseURL = strings.TrimRight(strings.TrimSpace(u.BaseURL), "/")
|
u.BaseURL = strings.TrimRight(strings.TrimSpace(u.BaseURL), "/")
|
||||||
u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL)
|
u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL)
|
||||||
u.TokenHeader = strings.TrimSpace(u.TokenHeader)
|
u.TokenHeader = strings.TrimSpace(u.TokenHeader)
|
||||||
|
u.TokenEnvNames = normalizeStringList(u.TokenEnvNames)
|
||||||
|
}
|
||||||
|
|
||||||
envNames := u.TokenEnvNames[:0]
|
func (e *Environment) normalize() {
|
||||||
for _, envName := range u.TokenEnvNames {
|
e.Known = normalizeStringList(e.Known)
|
||||||
if trimmed := strings.TrimSpace(envName); trimmed != "" {
|
|
||||||
envNames = append(envNames, trimmed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SecretStore) normalize() {
|
||||||
|
s.BackendPolicy = strings.TrimSpace(s.BackendPolicy)
|
||||||
}
|
}
|
||||||
u.TokenEnvNames = envNames
|
|
||||||
|
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 {
|
func (u Update) ReleaseSource() update.ReleaseSource {
|
||||||
|
|
@ -121,3 +177,38 @@ func (u Update) ReleaseSource() update.ReleaseSource {
|
||||||
TokenEnvNames: append([]string(nil), u.TokenEnvNames...),
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -114,3 +115,82 @@ func TestLoadReturnsParseError(t *testing.T) {
|
||||||
t.Fatal("expected error")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue