feat: generate manifest helper glue

This commit is contained in:
thibaud-lclr 2026-05-02 11:57:44 +02:00
parent 20b5026f9d
commit a79f73825f
3 changed files with 439 additions and 12 deletions

View file

@ -1,8 +1,7 @@
# Génération depuis `mcp.toml` # Génération depuis `mcp.toml`
La commande `mcp-framework generate` génère la glue Go dérivée du manifeste 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 racine d'un projet existant.
manifeste embarqué.
## Usage ## Usage
@ -18,9 +17,12 @@ génère :
```text ```text
mcpgen/ mcpgen/
manifest.go 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 ```go
func LoadManifest(startDir string) (manifest.File, string, error) 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, `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. 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 ## Flags
- `--manifest` : chemin du `mcp.toml` à lire. Par défaut, `./mcp.toml`. - `--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`. `example.com/my-mcp/mcpgen`.
4. Remplacer les appels `manifest.Load(...)` du wrapper par 4. Remplacer les appels `manifest.Load(...)` du wrapper par
`mcpgen.LoadManifest(...)`. `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 Après génération, un simple `go build ./...` suffit. La compilation ne dépend
pas de la commande `mcp-framework`. pas de la commande `mcp-framework`.

View file

@ -36,7 +36,8 @@ func Generate(options Options) (Result, error) {
return Result{}, err return Result{}, err
} }
if _, err := manifest.Load(normalized.ManifestPath); err != nil { manifestFile, err := manifest.Load(normalized.ManifestPath)
if err != nil {
return Result{}, err return Result{}, err
} }
@ -45,7 +46,19 @@ func Generate(options Options) (Result, error) {
return Result{}, fmt.Errorf("read manifest %s: %w", normalized.ManifestPath, err) 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 { if err != nil {
return Result{}, err return Result{}, err
} }
@ -53,7 +66,22 @@ func Generate(options Options) (Result, error) {
files := []generatedFile{ files := []generatedFile{
{ {
Path: filepath.Join(normalized.PackageDir, "manifest.go"), 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, Mode: 0o644,
}, },
} }
@ -189,6 +217,209 @@ func LoadManifest(startDir string) (fwmanifest.File, string, error) {
return string(formatted), nil 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 { func writeGeneratedFile(path, content string, mode os.FileMode) error {
current, err := os.ReadFile(path) current, err := os.ReadFile(path)
if err == nil && bytes.Equal(current, []byte(content)) { if err == nil && bytes.Equal(current, []byte(content)) {

View file

@ -24,7 +24,7 @@ description = "Demo MCP"
t.Fatalf("Generate returned error: %v", err) 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) 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) { func TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) {
projectDir := newProject(t, `binary_name = "demo-mcp"`) projectDir := newProject(t, `binary_name = "demo-mcp"`)
@ -106,7 +187,7 @@ func TestGenerateSupportsManifestAndPackageFlags(t *testing.T) {
t.Fatalf("Generate returned error: %v", err) 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) t.Fatalf("result files = %v", result.Files)
} }
@ -135,6 +216,17 @@ func TestGeneratedLoaderFallsBackToEmbeddedManifest(t *testing.T) {
projectDir := newProject(t, ` projectDir := newProject(t, `
binary_name = "embedded-demo" binary_name = "embedded-demo"
docs_url = "https://docs.example.com/embedded" 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) writeModule(t, projectDir)
@ -146,7 +238,7 @@ docs_url = "https://docs.example.com/embedded"
t.Fatalf("Remove runtime manifest: %v", err) t.Fatalf("Remove runtime manifest: %v", err)
} }
cmd := exec.Command("go", "test", "./...") cmd := exec.Command("go", "test", "-mod=mod", "./...")
cmd.Dir = projectDir cmd.Dir = projectDir
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@ -164,6 +256,15 @@ func newProject(t *testing.T, manifest string) string {
return projectDir 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) { func writeModule(t *testing.T, projectDir string) {
t.Helper() t.Helper()
@ -172,7 +273,7 @@ func writeModule(t *testing.T, projectDir string) {
t.Fatalf("Abs repo root: %v", err) 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 { if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil {
t.Fatalf("WriteFile go.mod: %v", err) t.Fatalf("WriteFile go.mod: %v", err)
} }
@ -188,13 +289,15 @@ func writeModule(t *testing.T, projectDir string) {
testFile := `package main testFile := `package main
import ( import (
"io"
"testing" "testing"
fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
"example.com/generated-demo/mcpgen" "example.com/generated-demo/mcpgen"
fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
) )
func TestGeneratedLoaderUsesEmbeddedManifest(t *testing.T) { func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) {
file, source, err := mcpgen.LoadManifest(".") file, source, err := mcpgen.LoadManifest(".")
if err != nil { if err != nil {
t.Fatalf("LoadManifest returned error: %v", err) t.Fatalf("LoadManifest returned error: %v", err)
@ -205,6 +308,50 @@ func TestGeneratedLoaderUsesEmbeddedManifest(t *testing.T) {
if file.BinaryName != "embedded-demo" { if file.BinaryName != "embedded-demo" {
t.Fatalf("binary name = %q", file.BinaryName) 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 { if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil {