diff --git a/README.md b/README.md index e9b682d..800642b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go index a4a263f..bcc9345 100644 --- a/cmd/mcp-framework/main.go +++ b/cmd/mcp-framework/main.go @@ -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 [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", + "Usage:\n %s [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, diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go index 0b17192..7468085 100644 --- a/cmd/mcp-framework/main_test.go +++ b/cmd/mcp-framework/main_test.go @@ -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 diff --git a/docs/README.md b/docs/README.md index 83fedb6..4b0360e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/generate.md b/docs/generate.md new file mode 100644 index 0000000..6f9ea58 --- /dev/null +++ b/docs/generate.md @@ -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`. diff --git a/generate/generate.go b/generate/generate.go new file mode 100644 index 0000000..7d4a497 --- /dev/null +++ b/generate/generate.go @@ -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 +} diff --git a/generate/generate_test.go b/generate/generate_test.go new file mode 100644 index 0000000..f75718e --- /dev/null +++ b/generate/generate_test.go @@ -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) + } +}