feat(manifest): add embedded runtime fallback for scaffolded apps

This commit is contained in:
thibaud-lclr 2026-04-16 16:56:00 +02:00
parent 01c0c7e1bc
commit a9378885f2
10 changed files with 217 additions and 17 deletions

View file

@ -55,6 +55,7 @@ type DoctorOptions struct {
SecretStoreFactory func() (secretstore.Store, error) SecretStoreFactory func() (secretstore.Store, error)
ManifestDir string ManifestDir string
ManifestValidator DoctorManifestValidator ManifestValidator DoctorManifestValidator
ManifestCheck DoctorCheck
ConnectivityCheck DoctorCheck ConnectivityCheck DoctorCheck
ExtraChecks []DoctorCheck ExtraChecks []DoctorCheck
} }
@ -71,7 +72,11 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
if len(options.RequiredSecrets) > 0 && options.SecretStoreFactory != nil { if len(options.RequiredSecrets) > 0 && options.SecretStoreFactory != nil {
checks = append(checks, RequiredSecretsCheck(options.SecretStoreFactory, options.RequiredSecrets)) checks = append(checks, RequiredSecretsCheck(options.SecretStoreFactory, options.RequiredSecrets))
} }
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator)) if options.ManifestCheck != nil {
checks = append(checks, options.ManifestCheck)
} else {
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
}
if options.ConnectivityCheck != nil { if options.ConnectivityCheck != nil {
checks = append(checks, options.ConnectivityCheck) checks = append(checks, options.ConnectivityCheck)
} }

View file

@ -132,6 +132,31 @@ func TestManifestCheckUsesValidator(t *testing.T) {
} }
} }
func TestRunDoctorUsesCustomManifestCheckWhenProvided(t *testing.T) {
report := RunDoctor(context.Background(), DoctorOptions{
ManifestDir: t.TempDir(),
ManifestCheck: func(context.Context) DoctorResult {
return DoctorResult{
Name: "manifest",
Status: DoctorStatusOK,
Summary: "manifest is embedded",
Detail: "embedded:mcp.toml",
}
},
})
if len(report.Results) != 1 {
t.Fatalf("result count = %d, want 1", len(report.Results))
}
result := report.Results[0]
if result.Summary != "manifest is embedded" {
t.Fatalf("summary = %q", result.Summary)
}
if result.Detail != "embedded:mcp.toml" {
t.Fatalf("detail = %q", result.Detail)
}
}
func TestRenderDoctorReportFormatsStatusesAndSummary(t *testing.T) { func TestRenderDoctorReportFormatsStatusesAndSummary(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
report := DoctorReport{ report := DoctorReport{

View file

@ -35,6 +35,6 @@ Le flux typique côté application est :
2. Résoudre le profil actif avec `cli`. 2. Résoudre le profil actif avec `cli`.
3. Charger la config versionnée avec `config`. 3. Charger la config versionnée avec `config`.
4. Lire les secrets avec `secretstore`. 4. Lire les secrets avec `secretstore`.
5. Charger `mcp.toml` avec `manifest`. 5. Charger le manifest runtime avec `manifest` (`mcp.toml` local, ou fallback embarqué).
6. Exécuter l'auto-update avec `update` si nécessaire. 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. 7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier.

View file

@ -1,6 +1,7 @@
# Manifeste `mcp.toml` # 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. Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire courant puis remonte les répertoires parents jusqu'à trouver le fichier.
Pour un binaire installé (par exemple dans `~/.local/bin`), il peut aussi charger un fallback embarqué via `LoadDefaultOrEmbedded`.
Exemple minimal : Exemple minimal :
@ -81,3 +82,14 @@ _ = bootstrapInfo
_ = scaffoldInfo _ = scaffoldInfo
_ = source _ = source
``` ```
Fallback fichier puis embarqué :
```go
file, source, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest)
if err != nil {
return err
}
fmt.Printf("manifest source: %s\n", source) // chemin du fichier ou "embedded:mcp.toml"
```

View file

@ -5,6 +5,8 @@ type Profile struct {
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
} }
var embeddedManifest = `...` // fallback utilisé si aucun mcp.toml runtime n'est trouvé
func run(ctx context.Context, flagProfile string) error { func run(ctx context.Context, flagProfile string) error {
cfgStore := config.NewStore[Profile]("my-mcp") cfgStore := config.NewStore[Profile]("my-mcp")
@ -16,7 +18,7 @@ func run(ctx context.Context, flagProfile string) error {
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
profile := cfg.Profiles[profileName] profile := cfg.Profiles[profileName]
manifestFile, _, err := manifest.LoadDefault(".") manifestFile, _, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest)
if err != nil { if err != nil {
return err return err
} }

View file

@ -3,7 +3,7 @@
- `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, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. - `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, 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`, `install.sh` wizard, wiring de base et README de démarrage). - `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage).
- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`. - `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. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.

View file

@ -13,6 +13,7 @@ import (
) )
const DefaultFile = "mcp.toml" const DefaultFile = "mcp.toml"
const EmbeddedSource = "embedded:mcp.toml"
type File struct { type File struct {
BinaryName string `toml:"binary_name"` BinaryName string `toml:"binary_name"`
@ -118,9 +119,39 @@ func Load(path string) (File, error) {
return File{}, fmt.Errorf("read manifest %s: %w", path, err) return File{}, fmt.Errorf("read manifest %s: %w", path, err)
} }
return parse(data, path)
}
func LoadEmbedded(content string) (File, string, error) {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return File{}, "", os.ErrNotExist
}
file, err := parse([]byte(trimmed), EmbeddedSource)
if err != nil {
return File{}, "", err
}
return file, EmbeddedSource, nil
}
func LoadDefaultOrEmbedded(startDir, embeddedContent string) (File, string, error) {
file, path, err := LoadDefault(startDir)
if err == nil {
return file, path, nil
}
if !errors.Is(err, os.ErrNotExist) {
return File{}, "", err
}
return LoadEmbedded(embeddedContent)
}
func parse(data []byte, source string) (File, error) {
var file File var file File
if err := toml.Unmarshal(data, &file); err != nil { if err := toml.Unmarshal(data, &file); err != nil {
return File{}, fmt.Errorf("parse manifest %s: %w", path, err) return File{}, fmt.Errorf("parse manifest %s: %w", source, err)
} }
file.normalize() file.normalize()

View file

@ -234,3 +234,64 @@ description = " Client MCP interne "
t.Fatalf("scaffold known environment variables = %v", scaffold.KnownEnvironmentVariables) t.Fatalf("scaffold known environment variables = %v", scaffold.KnownEnvironmentVariables)
} }
} }
func TestLoadEmbeddedParsesContent(t *testing.T) {
file, source, err := LoadEmbedded(`
[update]
latest_release_url = "https://example.com/latest"
`)
if err != nil {
t.Fatalf("LoadEmbedded returned error: %v", err)
}
if source != EmbeddedSource {
t.Fatalf("source = %q, want %q", source, EmbeddedSource)
}
if file.Update.LatestReleaseURL != "https://example.com/latest" {
t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL)
}
}
func TestLoadEmbeddedReturnsNotExistWhenEmpty(t *testing.T) {
_, _, err := LoadEmbedded(" ")
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("error = %v, want os.ErrNotExist", err)
}
}
func TestLoadDefaultOrEmbeddedPrefersManifestFile(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, DefaultFile)
if err := os.WriteFile(path, []byte("[update]\nlatest_release_url = \"https://example.com/from-file\"\n"), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
file, source, err := LoadDefaultOrEmbedded(root, `
[update]
latest_release_url = "https://example.com/from-embedded"
`)
if err != nil {
t.Fatalf("LoadDefaultOrEmbedded returned error: %v", err)
}
if source != path {
t.Fatalf("source = %q, want %q", source, path)
}
if file.Update.LatestReleaseURL != "https://example.com/from-file" {
t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL)
}
}
func TestLoadDefaultOrEmbeddedUsesEmbeddedWhenFileMissing(t *testing.T) {
file, source, err := LoadDefaultOrEmbedded(t.TempDir(), `
[update]
latest_release_url = "https://example.com/from-embedded"
`)
if err != nil {
t.Fatalf("LoadDefaultOrEmbedded returned error: %v", err)
}
if source != EmbeddedSource {
t.Fatalf("source = %q, want %q", source, EmbeddedSource)
}
if file.Update.LatestReleaseURL != "https://example.com/from-embedded" {
t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL)
}
}

View file

@ -888,6 +888,38 @@ import (
"gitea.lclr.dev/AI/mcp-framework/update" "gitea.lclr.dev/AI/mcp-framework/update"
) )
var embeddedManifest = ` + "`" + `binary_name = "{{.BinaryName}}"
docs_url = "{{.DocsURL}}"
[update]
source_name = "Release endpoint"
driver = "{{.ReleaseDriver}}"
repository = "{{.ReleaseRepository}}"
base_url = "{{.ReleaseBaseURL}}"
asset_name_template = "{binary}-{os}-{arch}{ext}"
checksum_asset_name = "{asset}.sha256"
checksum_required = true
signature_asset_name = "{asset}.sig"
signature_required = false
signature_public_key_env_names = ["{{.ReleasePublicKeyEnv}}"]
token_header = "Authorization"
token_prefix = "token"
token_env_names = ["{{.ReleaseTokenEnv}}"]
[environment]
known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}]
[secret_store]
backend_policy = "{{.SecretStorePolicy}}"
[profiles]
default = "{{.DefaultProfile}}"
known = [{{- range $index, $value := .Profiles}}{{if $index}}, {{end}}"{{$value}}"{{- end}}]
[bootstrap]
description = "{{.Description}}"
` + "`" + `
type Profile struct { type Profile struct {
BaseURL string BaseURL string
} }
@ -895,6 +927,7 @@ type Profile struct {
type Runtime struct { type Runtime struct {
ConfigStore config.Store[Profile] ConfigStore config.Store[Profile]
Manifest manifest.File Manifest manifest.File
ManifestSource string
BinaryName string BinaryName string
Description string Description string
Version string Version string
@ -921,12 +954,13 @@ func NewRuntime(version string) (Runtime, error) {
} }
} }
manifestFile, _, err := manifest.LoadDefault(manifestStartDir) manifestFile, manifestSource, err := manifest.LoadDefaultOrEmbedded(manifestStartDir, embeddedManifest)
if err != nil { if err != nil {
if !errors.Is(err, os.ErrNotExist) { if !errors.Is(err, os.ErrNotExist) {
return Runtime{}, err return Runtime{}, err
} }
manifestFile = manifest.File{} manifestFile = manifest.File{}
manifestSource = ""
} }
bootstrapInfo := manifestFile.BootstrapInfo() bootstrapInfo := manifestFile.BootstrapInfo()
@ -944,13 +978,14 @@ func NewRuntime(version string) (Runtime, error) {
tokenEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[2], tokenEnv) tokenEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[2], tokenEnv)
} }
return Runtime{ return Runtime{
ConfigStore: config.NewStore[Profile](binaryName), ConfigStore: config.NewStore[Profile](binaryName),
Manifest: manifestFile, Manifest: manifestFile,
BinaryName: binaryName, ManifestSource: manifestSource,
Description: description, BinaryName: binaryName,
Version: firstNonEmpty(strings.TrimSpace(version), "dev"), Description: description,
DefaultProfile: defaultProfile, Version: firstNonEmpty(strings.TrimSpace(version), "dev"),
DefaultProfile: defaultProfile,
ProfileEnv: profileEnv, ProfileEnv: profileEnv,
TokenEnv: tokenEnv, TokenEnv: tokenEnv,
SecretName: binaryName + "-api-token", SecretName: binaryName + "-api-token",
@ -1113,7 +1148,7 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er
{Name: r.SecretName, Label: "API token"}, {Name: r.SecretName, Label: "API token"},
}, },
SecretStoreFactory: r.openSecretStore, SecretStoreFactory: r.openSecretStore,
ManifestDir: ".", ManifestCheck: r.manifestDoctorCheck(),
}) })
if err := cli.RenderDoctorReport(stdout, report); err != nil { if err := cli.RenderDoctorReport(stdout, report); err != nil {
@ -1142,8 +1177,14 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error
} }
func (r Runtime) openSecretStore() (secretstore.Store, error) { func (r Runtime) openSecretStore() (secretstore.Store, error) {
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ backendPolicy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy))
if backendPolicy == "" {
backendPolicy = secretstore.BackendAuto
}
return secretstore.Open(secretstore.Options{
ServiceName: r.BinaryName, ServiceName: r.BinaryName,
BackendPolicy: backendPolicy,
LookupEnv: func(name string) (string, bool) { LookupEnv: func(name string) (string, bool) {
if name == r.SecretName { if name == r.SecretName {
return os.LookupEnv(r.TokenEnv) return os.LookupEnv(r.TokenEnv)
@ -1179,6 +1220,26 @@ func firstNonEmpty(values ...string) string {
} }
return "" return ""
} }
func (r Runtime) manifestDoctorCheck() cli.DoctorCheck {
return func(context.Context) cli.DoctorResult {
source := strings.TrimSpace(r.ManifestSource)
if source == "" {
return cli.DoctorResult{
Name: "manifest",
Status: cli.DoctorStatusWarn,
Summary: "manifest is missing, using built-in defaults",
}
}
return cli.DoctorResult{
Name: "manifest",
Status: cli.DoctorStatusOK,
Summary: "manifest is valid",
Detail: source,
}
}
}
` `
const manifestTemplate = `binary_name = "{{.BinaryName}}" const manifestTemplate = `binary_name = "{{.BinaryName}}"

View file

@ -66,12 +66,15 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
} }
for _, snippet := range []string{ for _, snippet := range []string{
"config.NewStore[Profile]", "config.NewStore[Profile]",
"secretstore.OpenFromManifest", "secretstore.Open(secretstore.Options",
"update.Run", "update.Run",
"manifest.LoadDefault", "manifest.LoadDefaultOrEmbedded",
"bootstrap.Run", "bootstrap.Run",
"os.Executable()", "os.Executable()",
"errors.Is(err, os.ErrNotExist)", "errors.Is(err, os.ErrNotExist)",
`var embeddedManifest = `,
"ManifestSource",
"ManifestCheck: r.manifestDoctorCheck()",
} { } {
if !strings.Contains(string(appGo), snippet) { if !strings.Contains(string(appGo), snippet) {
t.Fatalf("app.go missing snippet %q", snippet) t.Fatalf("app.go missing snippet %q", snippet)