diff --git a/cli/doctor.go b/cli/doctor.go index 8dfca94..27b15a8 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -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)) } - 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 { checks = append(checks, options.ConnectivityCheck) } diff --git a/cli/doctor_test.go b/cli/doctor_test.go index 04dc23f..04aeb99 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -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{ diff --git a/docs/getting-started.md b/docs/getting-started.md index 9aef506..b146f73 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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. diff --git a/docs/manifest.md b/docs/manifest.md index 6efd016..0e3b064 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -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" +``` diff --git a/docs/minimal-example.md b/docs/minimal-example.md index ce0b185..2cf682d 100644 --- a/docs/minimal-example.md +++ b/docs/minimal-example.md @@ -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 } diff --git a/docs/packages.md b/docs/packages.md index 12aae8a..4c181c2 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -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. diff --git a/manifest/manifest.go b/manifest/manifest.go index 544e59a..b35dcf5 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -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() diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 5d6a739..46b0ec0 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -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) + } +} diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 81e9fe7..1300206 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -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() @@ -944,13 +978,14 @@ func NewRuntime(version string) (Runtime, error) { tokenEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[2], tokenEnv) } - return Runtime{ - ConfigStore: config.NewStore[Profile](binaryName), - Manifest: manifestFile, - BinaryName: binaryName, - Description: description, - Version: firstNonEmpty(strings.TrimSpace(version), "dev"), - DefaultProfile: defaultProfile, + return Runtime{ + ConfigStore: config.NewStore[Profile](binaryName), + Manifest: manifestFile, + ManifestSource: manifestSource, + BinaryName: binaryName, + Description: description, + Version: firstNonEmpty(strings.TrimSpace(version), "dev"), + DefaultProfile: defaultProfile, ProfileEnv: profileEnv, TokenEnv: tokenEnv, 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"}, }, 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}}" diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index be726db..2f59ace 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -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)