From a79f73825f149926d0197764cccba2bebad2c2d4 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Sat, 2 May 2026 11:57:44 +0200 Subject: [PATCH] feat: generate manifest helper glue --- docs/generate.md | 57 ++++++++- generate/generate.go | 237 +++++++++++++++++++++++++++++++++++++- generate/generate_test.go | 157 ++++++++++++++++++++++++- 3 files changed, 439 insertions(+), 12 deletions(-) diff --git a/docs/generate.md b/docs/generate.md index 6f9ea58..ebf0d03 100644 --- a/docs/generate.md +++ b/docs/generate.md @@ -1,8 +1,7 @@ # Génération depuis `mcp.toml` La commande `mcp-framework generate` génère la glue Go dérivée du manifeste -racine d'un projet existant. Le premier usage couvert est le loader de -manifeste embarqué. +racine d'un projet existant. ## Usage @@ -18,9 +17,12 @@ génère : ```text mcpgen/ manifest.go + metadata.go + update.go + secretstore.go ``` -Le fichier généré expose : +Le package généré expose le loader de manifeste : ```go func LoadManifest(startDir string) (manifest.File, string, error) @@ -30,6 +32,48 @@ Cette fonction appelle `manifest.LoadDefaultOrEmbedded`. En développement, un `mcp.toml` présent sur disque reste prioritaire. Pour un binaire copié seul, elle utilise le contenu du manifeste embarqué au moment de la génération. +Il expose aussi des helpers dérivés du manifeste : + +```go +const BinaryName = "my-mcp" +const DefaultDescription = "..." +const DocsURL = "..." + +func BootstrapInfo(startDir string) (manifest.BootstrapMetadata, string, error) +func ScaffoldInfo(startDir string) (manifest.ScaffoldMetadata, string, error) +``` + +Pour l'auto-update : + +```go +func UpdateOptions(version string, stdout io.Writer) (update.Options, error) +func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (update.Options, error) +func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error +func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error +``` + +`RunUpdate` parse les flags de la commande `update`, refuse les arguments +positionnels, charge le manifeste via `LoadManifest`, puis appelle +`update.Run`. + +Pour les secrets : + +```go +type SecretStoreOptions struct { + ServiceName string + LookupEnv func(string) (string, bool) +} + +func OpenSecretStore(options SecretStoreOptions) (secretstore.Store, error) +func DescribeSecretRuntime(options SecretStoreOptions) (secretstore.RuntimeDescription, error) +func PreflightSecretStore(options SecretStoreOptions) (secretstore.PreflightReport, error) +``` + +`SecretStoreOptions` contient aussi les options techniques du package +`secretstore` (`KWalletAppID`, `KWalletFolder`, `BitwardenCommand`, +`BitwardenDebug`, `Shell`, `ExecutableResolver`). Si `ServiceName` est vide, +le nom du binaire déclaré dans le manifeste est utilisé. + ## Flags - `--manifest` : chemin du `mcp.toml` à lire. Par défaut, `./mcp.toml`. @@ -53,7 +97,12 @@ Pour remplacer un wrapper local du type `internal/manifest` : `example.com/my-mcp/mcpgen`. 4. Remplacer les appels `manifest.Load(...)` du wrapper par `mcpgen.LoadManifest(...)`. -5. Supprimer l'ancien wrapper manuel. +5. Remplacer les reconstructions locales d'options update par + `mcpgen.UpdateOptions(...)` ou `mcpgen.RunUpdate(...)`. +6. Remplacer les wrappers secret store qui ne font que brancher le loader par + `mcpgen.OpenSecretStore`, `mcpgen.DescribeSecretRuntime` et + `mcpgen.PreflightSecretStore`. +7. Supprimer l'ancien wrapper manuel. Après génération, un simple `go build ./...` suffit. La compilation ne dépend pas de la commande `mcp-framework`. diff --git a/generate/generate.go b/generate/generate.go index 7d4a497..711cedf 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -36,7 +36,8 @@ func Generate(options Options) (Result, error) { return Result{}, err } - if _, err := manifest.Load(normalized.ManifestPath); err != nil { + manifestFile, err := manifest.Load(normalized.ManifestPath) + if err != nil { return Result{}, err } @@ -45,7 +46,19 @@ func Generate(options Options) (Result, error) { return Result{}, fmt.Errorf("read manifest %s: %w", normalized.ManifestPath, err) } - content, err := renderManifestLoader(normalized.PackageName, string(manifestContent)) + manifestLoader, err := renderManifestLoader(normalized.PackageName, string(manifestContent)) + if err != nil { + return Result{}, err + } + metadata, err := renderMetadata(normalized.PackageName, manifestFile) + if err != nil { + return Result{}, err + } + update, err := renderUpdate(normalized.PackageName) + if err != nil { + return Result{}, err + } + secretstore, err := renderSecretStore(normalized.PackageName) if err != nil { return Result{}, err } @@ -53,7 +66,22 @@ func Generate(options Options) (Result, error) { files := []generatedFile{ { Path: filepath.Join(normalized.PackageDir, "manifest.go"), - Content: content, + Content: manifestLoader, + Mode: 0o644, + }, + { + Path: filepath.Join(normalized.PackageDir, "metadata.go"), + Content: metadata, + Mode: 0o644, + }, + { + Path: filepath.Join(normalized.PackageDir, "update.go"), + Content: update, + Mode: 0o644, + }, + { + Path: filepath.Join(normalized.PackageDir, "secretstore.go"), + Content: secretstore, Mode: 0o644, }, } @@ -189,6 +217,209 @@ func LoadManifest(startDir string) (fwmanifest.File, string, error) { return string(formatted), nil } +func renderMetadata(packageName string, manifestFile manifest.File) (string, error) { + bootstrapInfo := manifestFile.BootstrapInfo() + + source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. + +package %s + +import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" + +const BinaryName = %s +const DefaultDescription = %s +const DocsURL = %s + +func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) { + manifestFile, source, err := LoadManifest(startDir) + if err != nil { + return fwmanifest.BootstrapMetadata{}, "", err + } + + return manifestFile.BootstrapInfo(), source, nil +} + +func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) { + manifestFile, source, err := LoadManifest(startDir) + if err != nil { + return fwmanifest.ScaffoldMetadata{}, "", err + } + + return manifestFile.ScaffoldInfo(), source, nil +} +`, packageName, strconv.Quote(manifestFile.BinaryName), strconv.Quote(bootstrapInfo.Description), strconv.Quote(manifestFile.DocsURL)) + + return formatGenerated("metadata", source) +} + +func renderUpdate(packageName string) (string, error) { + source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. + +package %s + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + + fwupdate "gitea.lclr.dev/AI/mcp-framework/update" +) + +func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) { + return UpdateOptionsFrom(".", version, stdout) +} + +func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) { + manifestFile, _, err := LoadManifest(startDir) + if err != nil { + return fwupdate.Options{}, err + } + + binaryName := strings.TrimSpace(manifestFile.BinaryName) + if binaryName == "" { + binaryName = BinaryName + } + + return fwupdate.Options{ + CurrentVersion: version, + Stdout: stdout, + BinaryName: binaryName, + ReleaseSource: manifestFile.Update.ReleaseSource(), + }, nil +} + +func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error { + return RunUpdateFrom(ctx, args, ".", version, stdout) +} + +func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error { + fs := flag.NewFlagSet("update", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("update does not accept positional arguments: %%s", strings.Join(fs.Args(), ", ")) + } + + options, err := UpdateOptionsFrom(startDir, version, stdout) + if err != nil { + return err + } + + return fwupdate.Run(ctx, options) +} +`, packageName) + + return formatGenerated("update", source) +} + +func renderSecretStore(packageName string) (string, error) { + source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT. + +package %s + +import ( + "os" + "path/filepath" + "strings" + + fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type SecretStoreOptions struct { + ServiceName string + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string + BitwardenDebug bool + Shell string + ExecutableResolver fwsecretstore.ExecutableResolver +} + +func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) { + return fwsecretstore.OpenFromManifest(secretStoreOpenOptions(options)) +} + +func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) { + return fwsecretstore.DescribeRuntime(secretStoreDescribeOptions(options)) +} + +func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) { + return fwsecretstore.PreflightFromManifest(secretStoreDescribeOptions(options)) +} + +func secretStoreOpenOptions(options SecretStoreOptions) fwsecretstore.OpenFromManifestOptions { + return fwsecretstore.OpenFromManifestOptions{ + ServiceName: secretStoreServiceName(options), + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + BitwardenCommand: options.BitwardenCommand, + BitwardenDebug: options.BitwardenDebug, + Shell: options.Shell, + ManifestLoader: LoadManifest, + ExecutableResolver: options.ExecutableResolver, + } +} + +func secretStoreDescribeOptions(options SecretStoreOptions) fwsecretstore.DescribeRuntimeOptions { + return fwsecretstore.DescribeRuntimeOptions{ + ServiceName: secretStoreServiceName(options), + LookupEnv: options.LookupEnv, + KWalletAppID: options.KWalletAppID, + KWalletFolder: options.KWalletFolder, + BitwardenCommand: options.BitwardenCommand, + BitwardenDebug: options.BitwardenDebug, + Shell: options.Shell, + ManifestLoader: LoadManifest, + ExecutableResolver: options.ExecutableResolver, + } +} + +func secretStoreServiceName(options SecretStoreOptions) string { + serviceName := strings.TrimSpace(options.ServiceName) + if serviceName != "" { + return serviceName + } + + startDir := "." + executableResolver := options.ExecutableResolver + if executableResolver == nil { + executableResolver = os.Executable + } + if executablePath, err := executableResolver(); err == nil { + if dir := strings.TrimSpace(filepath.Dir(strings.TrimSpace(executablePath))); dir != "" { + startDir = dir + } + } + + if manifestFile, _, err := LoadManifest(startDir); err == nil { + if binaryName := strings.TrimSpace(manifestFile.BinaryName); binaryName != "" { + return binaryName + } + } + + return BinaryName +} +`, packageName) + + return formatGenerated("secretstore", source) +} + +func formatGenerated(name, source string) (string, error) { + formatted, err := format.Source([]byte(source)) + if err != nil { + return "", fmt.Errorf("format generated %s: %w", name, err) + } + + return string(formatted), nil +} + func writeGeneratedFile(path, content string, mode os.FileMode) error { current, err := os.ReadFile(path) if err == nil && bytes.Equal(current, []byte(content)) { diff --git a/generate/generate_test.go b/generate/generate_test.go index f75718e..0375689 100644 --- a/generate/generate_test.go +++ b/generate/generate_test.go @@ -24,7 +24,7 @@ description = "Demo MCP" t.Fatalf("Generate returned error: %v", err) } - if !slices.Equal(result.Files, []string{filepath.Join("mcpgen", "manifest.go")}) { + if !slices.Equal(result.Files, defaultGeneratedFiles("mcpgen")) { t.Fatalf("result files = %v", result.Files) } @@ -49,6 +49,87 @@ description = "Demo MCP" } } +func TestGenerateCreatesP1Helpers(t *testing.T) { + projectDir := newProject(t, ` +binary_name = "demo-mcp" +docs_url = "https://docs.example.com/demo" + +[update] +driver = "gitea" +repository = "org/demo-mcp" +base_url = "https://gitea.example.com" +asset_name_template = "{binary}-{os}-{arch}{ext}" + +[secret_store] +backend_policy = "env-only" + +[bootstrap] +description = "Demo MCP" +`) + + result, err := Generate(Options{ProjectDir: projectDir}) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + wantFiles := []string{ + filepath.Join("mcpgen", "manifest.go"), + filepath.Join("mcpgen", "metadata.go"), + filepath.Join("mcpgen", "secretstore.go"), + filepath.Join("mcpgen", "update.go"), + } + if !slices.Equal(result.Files, wantFiles) { + t.Fatalf("result files = %v, want %v", result.Files, wantFiles) + } + + metadata, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "metadata.go")) + if err != nil { + t.Fatalf("ReadFile metadata.go: %v", err) + } + for _, snippet := range []string{ + `const BinaryName = "demo-mcp"`, + `const DefaultDescription = "Demo MCP"`, + `const DocsURL = "https://docs.example.com/demo"`, + "func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) {", + "func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) {", + } { + if !strings.Contains(string(metadata), snippet) { + t.Fatalf("metadata.go missing snippet %q:\n%s", snippet, metadata) + } + } + + update, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "update.go")) + if err != nil { + t.Fatalf("ReadFile update.go: %v", err) + } + for _, snippet := range []string{ + "func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) {", + "func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) {", + "func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error {", + "ReleaseSource:", + } { + if !strings.Contains(string(update), snippet) { + t.Fatalf("update.go missing snippet %q:\n%s", snippet, update) + } + } + + secretstore, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go")) + if err != nil { + t.Fatalf("ReadFile secretstore.go: %v", err) + } + for _, snippet := range []string{ + "type SecretStoreOptions struct {", + "func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) {", + "func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) {", + "func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) {", + "ManifestLoader:", + } { + if !strings.Contains(string(secretstore), snippet) { + t.Fatalf("secretstore.go missing snippet %q:\n%s", snippet, secretstore) + } + } +} + func TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) { projectDir := newProject(t, `binary_name = "demo-mcp"`) @@ -106,7 +187,7 @@ func TestGenerateSupportsManifestAndPackageFlags(t *testing.T) { t.Fatalf("Generate returned error: %v", err) } - if !slices.Equal(result.Files, []string{filepath.Join("internal", "generated", "manifest.go")}) { + if !slices.Equal(result.Files, defaultGeneratedFiles(filepath.Join("internal", "generated"))) { t.Fatalf("result files = %v", result.Files) } @@ -135,6 +216,17 @@ func TestGeneratedLoaderFallsBackToEmbeddedManifest(t *testing.T) { projectDir := newProject(t, ` binary_name = "embedded-demo" docs_url = "https://docs.example.com/embedded" + +[update] +driver = "gitea" +repository = "org/embedded-demo" +base_url = "https://gitea.example.com" + +[secret_store] +backend_policy = "env-only" + +[bootstrap] +description = "Embedded Demo" `) writeModule(t, projectDir) @@ -146,7 +238,7 @@ docs_url = "https://docs.example.com/embedded" t.Fatalf("Remove runtime manifest: %v", err) } - cmd := exec.Command("go", "test", "./...") + cmd := exec.Command("go", "test", "-mod=mod", "./...") cmd.Dir = projectDir output, err := cmd.CombinedOutput() if err != nil { @@ -164,6 +256,15 @@ func newProject(t *testing.T, manifest string) string { return projectDir } +func defaultGeneratedFiles(packageDir string) []string { + return []string{ + filepath.Join(packageDir, "manifest.go"), + filepath.Join(packageDir, "metadata.go"), + filepath.Join(packageDir, "secretstore.go"), + filepath.Join(packageDir, "update.go"), + } +} + func writeModule(t *testing.T, projectDir string) { t.Helper() @@ -172,7 +273,7 @@ func writeModule(t *testing.T, projectDir string) { t.Fatalf("Abs repo root: %v", err) } - goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n" + goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n" if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil { t.Fatalf("WriteFile go.mod: %v", err) } @@ -188,13 +289,15 @@ func writeModule(t *testing.T, projectDir string) { testFile := `package main import ( + "io" "testing" + fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" "example.com/generated-demo/mcpgen" fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" ) -func TestGeneratedLoaderUsesEmbeddedManifest(t *testing.T) { +func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) { file, source, err := mcpgen.LoadManifest(".") if err != nil { t.Fatalf("LoadManifest returned error: %v", err) @@ -205,6 +308,50 @@ func TestGeneratedLoaderUsesEmbeddedManifest(t *testing.T) { if file.BinaryName != "embedded-demo" { t.Fatalf("binary name = %q", file.BinaryName) } + + info, source, err := mcpgen.BootstrapInfo(".") + if err != nil { + t.Fatalf("BootstrapInfo returned error: %v", err) + } + if source != fwmanifest.EmbeddedSource { + t.Fatalf("bootstrap source = %q, want %q", source, fwmanifest.EmbeddedSource) + } + if info.Description != "Embedded Demo" { + t.Fatalf("description = %q", info.Description) + } + + updateOptions, err := mcpgen.UpdateOptions("1.2.3", io.Discard) + if err != nil { + t.Fatalf("UpdateOptions returned error: %v", err) + } + if updateOptions.CurrentVersion != "1.2.3" { + t.Fatalf("current version = %q", updateOptions.CurrentVersion) + } + if updateOptions.BinaryName != "embedded-demo" { + t.Fatalf("update binary name = %q", updateOptions.BinaryName) + } + if updateOptions.ReleaseSource.Repository != "org/embedded-demo" { + t.Fatalf("release repository = %q", updateOptions.ReleaseSource.Repository) + } + + store, err := mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{ + LookupEnv: func(name string) (string, bool) { + return "secret-from-env", true + }, + }) + if err != nil { + t.Fatalf("OpenSecretStore returned error: %v", err) + } + value, err := store.GetSecret("profile/default/api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "secret-from-env" { + t.Fatalf("secret value = %q", value) + } + if fwsecretstore.EffectiveBackendPolicy(store) != fwsecretstore.BackendEnvOnly { + t.Fatalf("effective backend = %q", fwsecretstore.EffectiveBackendPolicy(store)) + } } ` if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil {