feat: add manifest code generation
This commit is contained in:
parent
ef22b1aa8a
commit
20b5026f9d
7 changed files with 596 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
59
docs/generate.md
Normal 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
214
generate/generate.go
Normal 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
213
generate/generate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue