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.
|
||||
- `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
|
||||
|
|
|
|||
|
|
@ -211,7 +211,14 @@ func trimArgs(args []string) []string {
|
|||
|
||||
func runConfigCommand(ctx context.Context, opts Options, args []string) error {
|
||||
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])
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) {
|
|||
if !errors.Is(err, ErrSubcommandRequired) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue