feat: add manifest code generation

This commit is contained in:
thibaud-lclr 2026-05-02 11:46:47 +02:00
parent ef22b1aa8a
commit 20b5026f9d
7 changed files with 596 additions and 1 deletions

View file

@ -7,6 +7,7 @@
- Le framework fournit des briques réutilisables : config locale, secrets, résolution CLI, manifeste projet, et auto-update.
- Il peut être utilisé de manière modulaire (package par package) ou avec un bootstrap CLI prêt à l'emploi.
- Il inclut un générateur de squelette (`mcp-framework scaffold init`) pour démarrer un nouveau binaire MCP rapidement.
- Il peut générer la glue Go dérivée d'un manifeste racine (`mcp-framework generate`).
- Toute la documentation détaillée est maintenant organisée dans `docs/` par grandes parties.
## Démarrage rapide
@ -43,6 +44,7 @@ go run ./cmd/my-mcp help
- Packages : [docs/packages.md](docs/packages.md)
- Bootstrap CLI : [docs/bootstrap-cli.md](docs/bootstrap-cli.md)
- Manifeste `mcp.toml` : [docs/manifest.md](docs/manifest.md)
- Génération depuis `mcp.toml` : [docs/generate.md](docs/generate.md)
- Scaffolding : [docs/scaffolding.md](docs/scaffolding.md)
- Config JSON : [docs/config.md](docs/config.md)
- Secrets : [docs/secrets.md](docs/secrets.md)

View file

@ -6,8 +6,10 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
generatepkg "gitea.lclr.dev/AI/mcp-framework/generate"
scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold"
)
@ -34,6 +36,8 @@ func run(args []string, stdout, stderr io.Writer) error {
}
switch args[0] {
case "generate":
return runGenerate(args[1:], stdout, stderr)
case "scaffold":
return runScaffold(args[1:], stdout, stderr)
default:
@ -41,6 +45,60 @@ func run(args []string, stdout, stderr io.Writer) error {
}
}
func runGenerate(args []string, stdout, stderr io.Writer) error {
if shouldShowHelp(args) {
printGenerateHelp(stdout)
return nil
}
fs := flag.NewFlagSet("generate", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var manifestPath string
var packageDir string
var packageName string
var check bool
fs.StringVar(&manifestPath, "manifest", "", "Chemin du mcp.toml à lire (défaut: ./mcp.toml)")
fs.StringVar(&packageDir, "package-dir", "mcpgen", "Répertoire du package Go généré")
fs.StringVar(&packageName, "package-name", "", "Nom du package Go généré (défaut: dérivé du dossier)")
fs.BoolVar(&check, "check", false, "Échoue si les fichiers générés ne sont pas à jour")
if err := fs.Parse(args); err != nil {
_ = stderr
return fmt.Errorf("parse generate flags: %w", err)
}
if fs.NArg() > 0 {
return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", "))
}
result, err := generatepkg.Generate(generatepkg.Options{
ManifestPath: manifestPath,
PackageDir: packageDir,
PackageName: packageName,
Check: check,
})
if err != nil {
return err
}
if check {
if _, err := fmt.Fprintln(stdout, "Generated files are up to date"); err != nil {
return err
}
return nil
}
for _, file := range result.Files {
if _, err := fmt.Fprintf(stdout, "Generated %s\n", filepath.ToSlash(file)); err != nil {
return err
}
}
return nil
}
func runScaffold(args []string, stdout, stderr io.Writer) error {
if len(args) == 0 || isHelpArg(args[0]) {
printScaffoldHelp(stdout)
@ -145,12 +203,20 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error {
func printGlobalHelp(w io.Writer) {
fmt.Fprintf(
w,
"Usage:\n %s <command> [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n",
"Usage:\n %s <command> [options]\n\nCommands:\n generate Génère la glue Go depuis mcp.toml\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n",
toolName,
toolName,
)
}
func printGenerateHelp(w io.Writer) {
fmt.Fprintf(
w,
"Usage:\n %s generate [flags]\n\nFlags:\n --manifest Chemin du mcp.toml à lire\n --package-dir Répertoire du package Go généré (défaut: mcpgen)\n --package-name Nom du package Go généré (défaut: dérivé du dossier)\n --check Vérifie que les fichiers générés sont à jour\n",
toolName,
)
}
func printScaffoldHelp(w io.Writer) {
fmt.Fprintf(
w,

View file

@ -60,6 +60,46 @@ func TestRunScaffoldInitCreatesProject(t *testing.T) {
}
}
func TestRunGenerateCreatesManifestLoader(t *testing.T) {
projectDir := t.TempDir()
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(`binary_name = "demo-mcp"`), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := run([]string{"generate", "--manifest", filepath.Join(projectDir, "mcp.toml")}, &stdout, &stderr)
if err != nil {
t.Fatalf("run returned error: %v", err)
}
if _, err := os.Stat(filepath.Join(projectDir, "mcpgen", "manifest.go")); err != nil {
t.Fatalf("generated manifest.go missing: %v", err)
}
if !strings.Contains(stdout.String(), "Generated mcpgen/manifest.go") {
t.Fatalf("stdout should include generation summary: %q", stdout.String())
}
}
func TestRunGenerateCheckReturnsErrorWhenOutdated(t *testing.T) {
projectDir := t.TempDir()
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(`binary_name = "demo-mcp"`), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := run([]string{"generate", "--manifest", filepath.Join(projectDir, "mcp.toml"), "--check"}, &stdout, &stderr)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "generated files are not up to date") {
t.Fatalf("error = %v", err)
}
}
func TestRunScaffoldInitRequiresTarget(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer

View file

@ -8,6 +8,7 @@ Cette documentation est organisée par grandes parties pour séparer la vue d'en
- [Packages](packages.md)
- [Bootstrap CLI](bootstrap-cli.md)
- [Manifeste `mcp.toml`](manifest.md)
- [Génération depuis `mcp.toml`](generate.md)
- [Scaffolding](scaffolding.md)
- [Config JSON](config.md)
- [Secrets](secrets.md)

59
docs/generate.md Normal file
View file

@ -0,0 +1,59 @@
# 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é.
## Usage
Depuis la racine du projet consommateur :
```bash
mcp-framework generate
```
La commande lit `./mcp.toml`, valide son contenu avec le package `manifest`, et
génère :
```text
mcpgen/
manifest.go
```
Le fichier généré expose :
```go
func LoadManifest(startDir string) (manifest.File, string, error)
```
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.
## Flags
- `--manifest` : chemin du `mcp.toml` à lire. Par défaut, `./mcp.toml`.
- `--package-dir` : répertoire du package généré. Par défaut, `mcpgen`.
- `--package-name` : nom du package Go généré. Par défaut, dérivé du dossier.
- `--check` : mode CI, échoue si les fichiers générés sont absents ou obsolètes.
Exemple CI :
```bash
mcp-framework generate --check
```
## Migration d'un wrapper manuel
Pour remplacer un wrapper local du type `internal/manifest` :
1. Déplacer le manifeste projet vers `mcp.toml` à la racine.
2. Lancer `mcp-framework generate`.
3. Remplacer les imports du wrapper local par le package généré, par exemple
`example.com/my-mcp/mcpgen`.
4. Remplacer les appels `manifest.Load(...)` du wrapper par
`mcpgen.LoadManifest(...)`.
5. 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`.

214
generate/generate.go Normal file
View file

@ -0,0 +1,214 @@
package generate
import (
"bytes"
"errors"
"fmt"
"go/format"
"go/token"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"gitea.lclr.dev/AI/mcp-framework/manifest"
)
var ErrGeneratedFilesOutdated = errors.New("generated files are not up to date")
type Options struct {
ProjectDir string
ManifestPath string
PackageDir string
PackageName string
Check bool
}
type Result struct {
Root string
Files []string
}
func Generate(options Options) (Result, error) {
normalized, err := normalizeOptions(options)
if err != nil {
return Result{}, err
}
if _, err := manifest.Load(normalized.ManifestPath); err != nil {
return Result{}, err
}
manifestContent, err := os.ReadFile(normalized.ManifestPath)
if err != nil {
return Result{}, fmt.Errorf("read manifest %s: %w", normalized.ManifestPath, err)
}
content, err := renderManifestLoader(normalized.PackageName, string(manifestContent))
if err != nil {
return Result{}, err
}
files := []generatedFile{
{
Path: filepath.Join(normalized.PackageDir, "manifest.go"),
Content: content,
Mode: 0o644,
},
}
written := make([]string, 0, len(files))
for _, file := range files {
target := filepath.Join(normalized.ProjectDir, file.Path)
if normalized.Check {
current, err := os.ReadFile(target)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path)
}
return Result{}, fmt.Errorf("read generated file %q: %w", target, err)
}
if !bytes.Equal(current, []byte(file.Content)) {
return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path)
}
written = append(written, file.Path)
continue
}
if err := writeGeneratedFile(target, file.Content, file.Mode); err != nil {
return Result{}, err
}
written = append(written, file.Path)
}
sort.Strings(written)
return Result{
Root: normalized.ProjectDir,
Files: written,
}, nil
}
type normalizedOptions struct {
ProjectDir string
ManifestPath string
PackageDir string
PackageName string
Check bool
}
type generatedFile struct {
Path string
Content string
Mode os.FileMode
}
func normalizeOptions(options Options) (normalizedOptions, error) {
manifestPath := strings.TrimSpace(options.ManifestPath)
projectDir := strings.TrimSpace(options.ProjectDir)
if manifestPath == "" {
baseDir := projectDir
if baseDir == "" {
wd, err := os.Getwd()
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err)
}
baseDir = wd
}
manifestPath = filepath.Join(baseDir, manifest.DefaultFile)
} else if !filepath.IsAbs(manifestPath) {
baseDir := projectDir
if baseDir == "" {
wd, err := os.Getwd()
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err)
}
baseDir = wd
}
manifestPath = filepath.Join(baseDir, manifestPath)
}
resolvedManifest, err := filepath.Abs(manifestPath)
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve manifest path %q: %w", manifestPath, err)
}
if projectDir == "" {
projectDir = filepath.Dir(resolvedManifest)
}
resolvedProjectDir, err := filepath.Abs(projectDir)
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve project dir %q: %w", projectDir, err)
}
packageDir := filepath.Clean(strings.TrimSpace(options.PackageDir))
if packageDir == "." || packageDir == "" {
packageDir = "mcpgen"
}
if filepath.IsAbs(packageDir) || packageDir == ".." || strings.HasPrefix(packageDir, ".."+string(filepath.Separator)) {
return normalizedOptions{}, fmt.Errorf("package dir %q must be relative to the project", options.PackageDir)
}
packageName := strings.TrimSpace(options.PackageName)
if packageName == "" {
packageName = filepath.Base(packageDir)
}
if !token.IsIdentifier(packageName) {
return normalizedOptions{}, fmt.Errorf("package name %q is not a valid Go identifier", packageName)
}
return normalizedOptions{
ProjectDir: resolvedProjectDir,
ManifestPath: resolvedManifest,
PackageDir: packageDir,
PackageName: packageName,
Check: options.Check,
}, nil
}
func renderManifestLoader(packageName, manifestContent string) (string, error) {
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
const embeddedManifest = %s
func LoadManifest(startDir string) (fwmanifest.File, string, error) {
return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)
}
`, packageName, strconv.Quote(manifestContent))
formatted, err := format.Source([]byte(source))
if err != nil {
return "", fmt.Errorf("format generated manifest loader: %w", 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)) {
return nil
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read generated file %q: %w", path, err)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create generated directory %q: %w", dir, err)
}
if mode == 0 {
mode = 0o644
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
return fmt.Errorf("write generated file %q: %w", path, err)
}
return nil
}

213
generate/generate_test.go Normal file
View file

@ -0,0 +1,213 @@
package generate
import (
"errors"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestGenerateCreatesManifestLoader(t *testing.T) {
projectDir := newProject(t, `
binary_name = "demo-mcp"
docs_url = "https://docs.example.com/demo"
[bootstrap]
description = "Demo MCP"
`)
result, err := Generate(Options{ProjectDir: projectDir})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !slices.Equal(result.Files, []string{filepath.Join("mcpgen", "manifest.go")}) {
t.Fatalf("result files = %v", result.Files)
}
generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go")
content, err := os.ReadFile(generatedPath)
if err != nil {
t.Fatalf("ReadFile generated manifest: %v", err)
}
for _, snippet := range []string{
"// Code generated by mcp-framework generate. DO NOT EDIT.",
"package mcpgen",
"import fwmanifest \"gitea.lclr.dev/AI/mcp-framework/manifest\"",
"const embeddedManifest = ",
"func LoadManifest(startDir string) (fwmanifest.File, string, error) {",
"return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)",
`binary_name = \"demo-mcp\"`,
} {
if !strings.Contains(string(content), snippet) {
t.Fatalf("generated manifest.go missing snippet %q:\n%s", snippet, content)
}
}
}
func TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) {
projectDir := newProject(t, `binary_name = "demo-mcp"`)
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
t.Fatalf("first Generate returned error: %v", err)
}
generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go")
first, err := os.ReadFile(generatedPath)
if err != nil {
t.Fatalf("ReadFile first generated file: %v", err)
}
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
t.Fatalf("second Generate returned error: %v", err)
}
second, err := os.ReadFile(generatedPath)
if err != nil {
t.Fatalf("ReadFile second generated file: %v", err)
}
if string(second) != string(first) {
t.Fatalf("second generation changed content")
}
if _, err := Generate(Options{ProjectDir: projectDir, Check: true}); err != nil {
t.Fatalf("check after generation returned error: %v", err)
}
if err := os.WriteFile(generatedPath, append(second, []byte("// drift\n")...), 0o600); err != nil {
t.Fatalf("WriteFile drift: %v", err)
}
_, err = Generate(Options{ProjectDir: projectDir, Check: true})
if !errors.Is(err, ErrGeneratedFilesOutdated) {
t.Fatalf("check error = %v, want ErrGeneratedFilesOutdated", err)
}
}
func TestGenerateSupportsManifestAndPackageFlags(t *testing.T) {
projectDir := t.TempDir()
manifestPath := filepath.Join(projectDir, "config", "custom.toml")
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
t.Fatalf("MkdirAll manifest dir: %v", err)
}
if err := os.WriteFile(manifestPath, []byte(`binary_name = "demo-mcp"`), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
result, err := Generate(Options{
ProjectDir: projectDir,
ManifestPath: manifestPath,
PackageDir: "internal/generated",
PackageName: "generated",
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !slices.Equal(result.Files, []string{filepath.Join("internal", "generated", "manifest.go")}) {
t.Fatalf("result files = %v", result.Files)
}
content, err := os.ReadFile(filepath.Join(projectDir, "internal", "generated", "manifest.go"))
if err != nil {
t.Fatalf("ReadFile generated manifest: %v", err)
}
if !strings.Contains(string(content), "package generated") {
t.Fatalf("generated file should use package name: %s", content)
}
}
func TestGenerateRejectsInvalidManifest(t *testing.T) {
projectDir := newProject(t, "[bootstrap\n")
_, err := Generate(Options{ProjectDir: projectDir})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "parse manifest") {
t.Fatalf("error = %v", err)
}
}
func TestGeneratedLoaderFallsBackToEmbeddedManifest(t *testing.T) {
projectDir := newProject(t, `
binary_name = "embedded-demo"
docs_url = "https://docs.example.com/embedded"
`)
writeModule(t, projectDir)
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if err := os.Remove(filepath.Join(projectDir, "mcp.toml")); err != nil {
t.Fatalf("Remove runtime manifest: %v", err)
}
cmd := exec.Command("go", "test", "./...")
cmd.Dir = projectDir
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go test generated project: %v\n%s", err, output)
}
}
func newProject(t *testing.T, manifest string) string {
t.Helper()
projectDir := t.TempDir()
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(manifest), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
return projectDir
}
func writeModule(t *testing.T, projectDir string) {
t.Helper()
repoRoot, err := filepath.Abs("..")
if err != nil {
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"
if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil {
t.Fatalf("WriteFile go.mod: %v", err)
}
goSum, err := os.ReadFile(filepath.Join(repoRoot, "go.sum"))
if err != nil {
t.Fatalf("ReadFile go.sum: %v", err)
}
if err := os.WriteFile(filepath.Join(projectDir, "go.sum"), goSum, 0o600); err != nil {
t.Fatalf("WriteFile go.sum: %v", err)
}
testFile := `package main
import (
"testing"
"example.com/generated-demo/mcpgen"
fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
)
func TestGeneratedLoaderUsesEmbeddedManifest(t *testing.T) {
file, source, err := mcpgen.LoadManifest(".")
if err != nil {
t.Fatalf("LoadManifest returned error: %v", err)
}
if source != fwmanifest.EmbeddedSource {
t.Fatalf("source = %q, want %q", source, fwmanifest.EmbeddedSource)
}
if file.BinaryName != "embedded-demo" {
t.Fatalf("binary name = %q", file.BinaryName)
}
}
`
if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil {
t.Fatalf("WriteFile main_test.go: %v", err)
}
}