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`
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`.

View file

@ -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)) {

View file

@ -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 {