feat(manifest): add embedded runtime fallback for scaffolded apps
This commit is contained in:
parent
01c0c7e1bc
commit
a9378885f2
10 changed files with 217 additions and 17 deletions
|
|
@ -55,6 +55,7 @@ type DoctorOptions struct {
|
|||
SecretStoreFactory func() (secretstore.Store, error)
|
||||
ManifestDir string
|
||||
ManifestValidator DoctorManifestValidator
|
||||
ManifestCheck DoctorCheck
|
||||
ConnectivityCheck DoctorCheck
|
||||
ExtraChecks []DoctorCheck
|
||||
}
|
||||
|
|
@ -71,7 +72,11 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
|
|||
if len(options.RequiredSecrets) > 0 && options.SecretStoreFactory != nil {
|
||||
checks = append(checks, RequiredSecretsCheck(options.SecretStoreFactory, options.RequiredSecrets))
|
||||
}
|
||||
if options.ManifestCheck != nil {
|
||||
checks = append(checks, options.ManifestCheck)
|
||||
} else {
|
||||
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
|
||||
}
|
||||
if options.ConnectivityCheck != nil {
|
||||
checks = append(checks, options.ConnectivityCheck)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
var out bytes.Buffer
|
||||
report := DoctorReport{
|
||||
|
|
|
|||
|
|
@ -35,6 +35,6 @@ Le flux typique côté application est :
|
|||
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`.
|
||||
5. Charger le manifest runtime avec `manifest` (`mcp.toml` local, ou fallback embarqué).
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# 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.
|
||||
Pour un binaire installé (par exemple dans `~/.local/bin`), il peut aussi charger un fallback embarqué via `LoadDefaultOrEmbedded`.
|
||||
|
||||
Exemple minimal :
|
||||
|
||||
|
|
@ -81,3 +82,14 @@ _ = bootstrapInfo
|
|||
_ = scaffoldInfo
|
||||
_ = 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"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ type Profile struct {
|
|||
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 {
|
||||
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)
|
||||
profile := cfg.Profiles[profileName]
|
||||
|
||||
manifestFile, _, err := manifest.LoadDefault(".")
|
||||
manifestFile, _, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
- `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.
|
||||
- `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).
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
)
|
||||
|
||||
const DefaultFile = "mcp.toml"
|
||||
const EmbeddedSource = "embedded:mcp.toml"
|
||||
|
||||
type File struct {
|
||||
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 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
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -234,3 +234,64 @@ description = " Client MCP interne "
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -888,6 +888,38 @@ import (
|
|||
"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 {
|
||||
BaseURL string
|
||||
}
|
||||
|
|
@ -895,6 +927,7 @@ type Profile struct {
|
|||
type Runtime struct {
|
||||
ConfigStore config.Store[Profile]
|
||||
Manifest manifest.File
|
||||
ManifestSource string
|
||||
BinaryName string
|
||||
Description 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 !errors.Is(err, os.ErrNotExist) {
|
||||
return Runtime{}, err
|
||||
}
|
||||
manifestFile = manifest.File{}
|
||||
manifestSource = ""
|
||||
}
|
||||
|
||||
bootstrapInfo := manifestFile.BootstrapInfo()
|
||||
|
|
@ -947,6 +981,7 @@ func NewRuntime(version string) (Runtime, error) {
|
|||
return Runtime{
|
||||
ConfigStore: config.NewStore[Profile](binaryName),
|
||||
Manifest: manifestFile,
|
||||
ManifestSource: manifestSource,
|
||||
BinaryName: binaryName,
|
||||
Description: description,
|
||||
Version: firstNonEmpty(strings.TrimSpace(version), "dev"),
|
||||
|
|
@ -1113,7 +1148,7 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er
|
|||
{Name: r.SecretName, Label: "API token"},
|
||||
},
|
||||
SecretStoreFactory: r.openSecretStore,
|
||||
ManifestDir: ".",
|
||||
ManifestCheck: r.manifestDoctorCheck(),
|
||||
})
|
||||
|
||||
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) {
|
||||
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,
|
||||
BackendPolicy: backendPolicy,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
if name == r.SecretName {
|
||||
return os.LookupEnv(r.TokenEnv)
|
||||
|
|
@ -1179,6 +1220,26 @@ func firstNonEmpty(values ...string) string {
|
|||
}
|
||||
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}}"
|
||||
|
|
|
|||
|
|
@ -66,12 +66,15 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
|
|||
}
|
||||
for _, snippet := range []string{
|
||||
"config.NewStore[Profile]",
|
||||
"secretstore.OpenFromManifest",
|
||||
"secretstore.Open(secretstore.Options",
|
||||
"update.Run",
|
||||
"manifest.LoadDefault",
|
||||
"manifest.LoadDefaultOrEmbedded",
|
||||
"bootstrap.Run",
|
||||
"os.Executable()",
|
||||
"errors.Is(err, os.ErrNotExist)",
|
||||
`var embeddedManifest = `,
|
||||
"ManifestSource",
|
||||
"ManifestCheck: r.manifestDoctorCheck()",
|
||||
} {
|
||||
if !strings.Contains(string(appGo), snippet) {
|
||||
t.Fatalf("app.go missing snippet %q", snippet)
|
||||
|
|
|
|||
Loading…
Reference in a new issue