Merge pull request 'feat: add MCP scaffold generator' (#20) from feat/mcp-binary-scaffold into release/v1.3

Reviewed-on: https://gitea.lclr.dev/AI/mcp-framework/pulls/20
This commit is contained in:
thibaud-lclr 2026-04-14 14:24:44 +00:00
commit 1b603f552c
5 changed files with 1270 additions and 0 deletions

View file

@ -17,12 +17,34 @@ pas une application MCP complète.
go get gitea.lclr.dev/AI/mcp-framework
```
## CLI de scaffold
Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go :
```bash
go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
mcp-framework scaffold init \
--target ./my-mcp \
--module example.com/my-mcp \
--binary my-mcp \
--profiles dev,prod
```
Puis dans le projet généré :
```bash
cd my-mcp
go mod tidy
go run ./cmd/my-mcp help
```
## Packages
- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites.
- `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`.
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
- `manifest` : lecture de `mcp.toml` à la racine du projet, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding.
- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, wiring de base et README de démarrage).
- `secretstore` : lecture/écriture de secrets dans le wallet natif.
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
@ -159,6 +181,32 @@ _ = bootstrapInfo
_ = scaffoldInfo
```
## Scaffolding
Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP :
- arborescence recommandée (`cmd/<binary>/main.go`, `internal/app/app.go`, `mcp.toml`)
- wiring initial `bootstrap + config + secretstore + update`
- `README.md` de démarrage
Exemple :
```go
result, err := scaffold.Generate(scaffold.Options{
TargetDir: "./my-mcp",
ModulePath: "gitea.lclr.dev/AI/my-mcp",
BinaryName: "my-mcp",
Description: "Client MCP interne",
DefaultProfile: "prod",
Profiles: []string{"dev", "prod"},
})
if err != nil {
return err
}
fmt.Printf("Scaffold generated in %s (%d files)\n", result.Root, len(result.Files))
```
## Config JSON
Le package `config` stocke une structure générique par profil dans un JSON privé

200
cmd/mcp-framework/main.go Normal file
View file

@ -0,0 +1,200 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold"
)
const toolName = "mcp-framework"
func main() {
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func run(args []string, stdout, stderr io.Writer) error {
if stdout == nil {
stdout = io.Discard
}
if stderr == nil {
stderr = io.Discard
}
if len(args) == 0 || isHelpArg(args[0]) {
printGlobalHelp(stdout)
return nil
}
switch args[0] {
case "scaffold":
return runScaffold(args[1:], stdout, stderr)
default:
return fmt.Errorf("unknown command %q", args[0])
}
}
func runScaffold(args []string, stdout, stderr io.Writer) error {
if len(args) == 0 || isHelpArg(args[0]) {
printScaffoldHelp(stdout)
return nil
}
switch args[0] {
case "init":
return runScaffoldInit(args[1:], stdout, stderr)
default:
return fmt.Errorf("unknown scaffold subcommand %q", args[0])
}
}
func runScaffoldInit(args []string, stdout, stderr io.Writer) error {
if shouldShowHelp(args) {
printScaffoldInitHelp(stdout)
return nil
}
fs := flag.NewFlagSet("scaffold init", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var target string
var modulePath string
var binaryName string
var description string
var docsURL string
var defaultProfile string
var profiles string
var knownEnv string
var secretStorePolicy string
var releaseDriver string
var releaseBaseURL string
var releaseRepository string
var releaseTokenEnv string
var overwrite bool
fs.StringVar(&target, "target", "", "Répertoire cible du nouveau projet (requis)")
fs.StringVar(&modulePath, "module", "", "Chemin de module Go du projet généré")
fs.StringVar(&binaryName, "binary", "", "Nom du binaire généré")
fs.StringVar(&description, "description", "", "Description bootstrap du binaire")
fs.StringVar(&docsURL, "docs-url", "", "URL de documentation du projet")
fs.StringVar(&defaultProfile, "default-profile", "", "Profil par défaut")
fs.StringVar(&profiles, "profiles", "", "Liste CSV de profils connus")
fs.StringVar(&knownEnv, "known-env", "", "Liste CSV de variables d'environnement connues")
fs.StringVar(&secretStorePolicy, "secret-store-policy", "", "Politique secret store (auto, keyring-any, kwallet-only, env-only)")
fs.StringVar(&releaseDriver, "release-driver", "", "Driver de release (gitea, gitlab, github)")
fs.StringVar(&releaseBaseURL, "release-base-url", "", "Base URL de la forge release")
fs.StringVar(&releaseRepository, "release-repository", "", "Repository release (org/repo)")
fs.StringVar(&releaseTokenEnv, "release-token-env", "", "Nom de variable d'environnement pour le token release")
fs.BoolVar(&overwrite, "overwrite", false, "Autorise l'écrasement des fichiers existants")
if err := fs.Parse(args); err != nil {
_ = stderr
return fmt.Errorf("parse scaffold init flags: %w", err)
}
if fs.NArg() > 0 {
return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", "))
}
if strings.TrimSpace(target) == "" {
return errors.New("--target is required")
}
result, err := scaffoldpkg.Generate(scaffoldpkg.Options{
TargetDir: target,
ModulePath: modulePath,
BinaryName: binaryName,
Description: description,
DocsURL: docsURL,
DefaultProfile: defaultProfile,
Profiles: parseCSV(profiles),
KnownEnvironmentVariables: parseCSV(knownEnv),
SecretStorePolicy: secretStorePolicy,
ReleaseDriver: releaseDriver,
ReleaseBaseURL: releaseBaseURL,
ReleaseRepository: releaseRepository,
ReleaseTokenEnv: releaseTokenEnv,
Overwrite: overwrite,
})
if err != nil {
return err
}
if _, err := fmt.Fprintf(stdout, "Scaffold generated in %s\n", result.Root); err != nil {
return err
}
for _, file := range result.Files {
if _, err := fmt.Fprintf(stdout, "- %s\n", file); err != nil {
return err
}
}
return nil
}
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",
toolName,
toolName,
)
}
func printScaffoldHelp(w io.Writer) {
fmt.Fprintf(
w,
"Usage:\n %s scaffold init [flags]\n\nSubcommands:\n init Génère un nouveau squelette MCP\n",
toolName,
)
}
func printScaffoldInitHelp(w io.Writer) {
fmt.Fprintf(
w,
"Usage:\n %s scaffold init --target <dir> [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --overwrite Écraser les fichiers existants\n",
toolName,
)
}
func shouldShowHelp(args []string) bool {
for _, arg := range args {
if isHelpArg(arg) {
return true
}
}
return false
}
func isHelpArg(arg string) bool {
switch strings.TrimSpace(arg) {
case "-h", "--help", "help":
return true
default:
return false
}
}
func parseCSV(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
result = append(result, trimmed)
}
return result
}

View file

@ -0,0 +1,101 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunPrintsGlobalHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := run(nil, &stdout, &stderr); err != nil {
t.Fatalf("run returned error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "mcp-framework <command>") {
t.Fatalf("global help should mention command usage: %q", output)
}
if !strings.Contains(output, "scaffold init") {
t.Fatalf("global help should mention scaffold init: %q", output)
}
}
func TestRunScaffoldInitCreatesProject(t *testing.T) {
target := filepath.Join(t.TempDir(), "demo-mcp")
args := []string{
"scaffold", "init",
"--target", target,
"--module", "example.com/demo-mcp",
"--binary", "demo-mcp",
"--profiles", "dev,prod",
}
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := run(args, &stdout, &stderr); err != nil {
t.Fatalf("run returned error: %v", err)
}
if _, err := os.Stat(filepath.Join(target, "cmd", "demo-mcp", "main.go")); err != nil {
t.Fatalf("generated main.go missing: %v", err)
}
if _, err := os.Stat(filepath.Join(target, "internal", "app", "app.go")); err != nil {
t.Fatalf("generated app.go missing: %v", err)
}
if _, err := os.Stat(filepath.Join(target, "mcp.toml")); err != nil {
t.Fatalf("generated mcp.toml missing: %v", err)
}
if !strings.Contains(stdout.String(), "Scaffold generated in") {
t.Fatalf("stdout should include generation summary: %q", stdout.String())
}
}
func TestRunScaffoldInitRequiresTarget(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := run([]string{"scaffold", "init"}, &stdout, &stderr)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--target is required") {
t.Fatalf("error = %v", err)
}
}
func TestRunUnknownCommandReturnsError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := run([]string{"boom"}, &stdout, &stderr)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "unknown command") {
t.Fatalf("error = %v", err)
}
}
func TestScaffoldInitHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := run([]string{"scaffold", "init", "--help"}, &stdout, &stderr); err != nil {
t.Fatalf("run returned error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "--target") {
t.Fatalf("init help should mention --target: %q", output)
}
if !strings.Contains(output, "--overwrite") {
t.Fatalf("init help should mention --overwrite: %q", output)
}
}

738
scaffold/scaffold.go Normal file
View file

@ -0,0 +1,738 @@
package scaffold
import (
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"text/template"
"unicode"
)
var (
ErrTargetDirRequired = errors.New("target directory is required")
ErrFileExists = errors.New("target file already exists")
)
type Options struct {
TargetDir string
ModulePath string
BinaryName string
Description string
DocsURL string
DefaultProfile string
Profiles []string
KnownEnvironmentVariables []string
SecretStorePolicy string
ReleaseDriver string
ReleaseBaseURL string
ReleaseRepository string
ReleaseTokenEnv string
Overwrite bool
}
type Result struct {
Root string
Files []string
}
type normalizedOptions struct {
TargetDir string
ModulePath string
BinaryName string
Description string
DocsURL string
DefaultProfile string
Profiles []string
KnownEnvironmentVariables []string
ProfileEnv string
BaseURLEnv string
TokenEnv string
SecretStorePolicy string
ReleaseDriver string
ReleaseBaseURL string
ReleaseRepository string
ReleaseTokenEnv string
Overwrite bool
}
func Generate(options Options) (Result, error) {
normalized, err := normalizeOptions(options)
if err != nil {
return Result{}, err
}
if err := os.MkdirAll(normalized.TargetDir, 0o755); err != nil {
return Result{}, fmt.Errorf("create scaffold target %q: %w", normalized.TargetDir, err)
}
files := []generatedFile{
{Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized)},
{Path: "go.mod", Content: renderTemplate(goModTemplate, normalized)},
{Path: "README.md", Content: renderTemplate(readmeTemplate, normalized)},
{Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized)},
{Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized)},
{Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized)},
}
written := make([]string, 0, len(files))
for _, file := range files {
fullPath := filepath.Join(normalized.TargetDir, file.Path)
if err := writeFile(fullPath, file.Content, normalized.Overwrite); err != nil {
return Result{}, err
}
written = append(written, file.Path)
}
sort.Strings(written)
return Result{
Root: normalized.TargetDir,
Files: written,
}, nil
}
type generatedFile struct {
Path string
Content string
}
func writeFile(path, content string, overwrite bool) error {
if !overwrite {
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("%w: %s", ErrFileExists, path)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat scaffold file %q: %w", path, err)
}
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create scaffold directory %q: %w", dir, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fmt.Errorf("write scaffold file %q: %w", path, err)
}
return nil
}
func renderTemplate(src string, data normalizedOptions) string {
tpl := template.Must(template.New("scaffold").Parse(src))
var builder strings.Builder
if err := tpl.Execute(&builder, data); err != nil {
panic(err)
}
return builder.String()
}
func normalizeOptions(options Options) (normalizedOptions, error) {
targetDir := strings.TrimSpace(options.TargetDir)
if targetDir == "" {
return normalizedOptions{}, ErrTargetDirRequired
}
resolvedTarget, err := filepath.Abs(targetDir)
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve scaffold target %q: %w", targetDir, err)
}
binaryName := strings.TrimSpace(options.BinaryName)
if binaryName == "" {
binaryName = sanitizeSlug(filepath.Base(resolvedTarget))
}
if binaryName == "" {
binaryName = "my-mcp"
}
if strings.ContainsRune(binaryName, os.PathSeparator) {
return normalizedOptions{}, fmt.Errorf("binary name %q must not contain path separators", binaryName)
}
modulePath := strings.TrimSpace(options.ModulePath)
if modulePath == "" {
modulePath = fmt.Sprintf("example.com/%s", sanitizeModuleSegment(binaryName))
}
description := strings.TrimSpace(options.Description)
if description == "" {
description = fmt.Sprintf("Binaire MCP %s.", binaryName)
}
docsURL := strings.TrimSpace(options.DocsURL)
if docsURL == "" {
docsURL = fmt.Sprintf("https://docs.example.com/%s", binaryName)
}
defaultProfile := strings.TrimSpace(options.DefaultProfile)
if defaultProfile == "" {
defaultProfile = "default"
}
profiles := normalizeValues(options.Profiles)
if !slices.Contains(profiles, defaultProfile) {
profiles = append([]string{defaultProfile}, profiles...)
}
if len(profiles) == 0 {
profiles = []string{defaultProfile}
}
envPrefix := environmentPrefix(binaryName)
profileEnv := envPrefix + "_PROFILE"
baseURLEnv := envPrefix + "_BASE_URL"
tokenEnv := envPrefix + "_API_TOKEN"
knownEnvironmentVariables := []string{profileEnv, baseURLEnv, tokenEnv}
for _, name := range normalizeValues(options.KnownEnvironmentVariables) {
if !slices.Contains(knownEnvironmentVariables, name) {
knownEnvironmentVariables = append(knownEnvironmentVariables, name)
}
}
secretStorePolicy := strings.TrimSpace(options.SecretStorePolicy)
if secretStorePolicy == "" {
secretStorePolicy = "auto"
}
releaseDriver := strings.TrimSpace(options.ReleaseDriver)
if releaseDriver == "" {
releaseDriver = "gitea"
}
releaseBaseURL := strings.TrimSpace(options.ReleaseBaseURL)
if releaseBaseURL == "" {
releaseBaseURL = "https://gitea.example.com"
}
releaseRepository := strings.Trim(strings.TrimSpace(options.ReleaseRepository), "/")
if releaseRepository == "" {
releaseRepository = fmt.Sprintf("org/%s", binaryName)
}
releaseTokenEnv := strings.TrimSpace(options.ReleaseTokenEnv)
if releaseTokenEnv == "" {
releaseTokenEnv = envPrefix + "_RELEASE_TOKEN"
}
return normalizedOptions{
TargetDir: resolvedTarget,
ModulePath: modulePath,
BinaryName: binaryName,
Description: description,
DocsURL: docsURL,
DefaultProfile: defaultProfile,
Profiles: profiles,
KnownEnvironmentVariables: knownEnvironmentVariables,
ProfileEnv: profileEnv,
BaseURLEnv: baseURLEnv,
TokenEnv: tokenEnv,
SecretStorePolicy: secretStorePolicy,
ReleaseDriver: releaseDriver,
ReleaseBaseURL: releaseBaseURL,
ReleaseRepository: releaseRepository,
ReleaseTokenEnv: releaseTokenEnv,
Overwrite: options.Overwrite,
}, nil
}
func normalizeValues(values []string) []string {
normalized := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
normalized = append(normalized, trimmed)
}
return normalized
}
func sanitizeSlug(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return ""
}
var builder strings.Builder
lastDash := false
for _, r := range value {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
builder.WriteRune(r)
lastDash = false
case r == '-' || r == '_' || r == ' ' || r == '.':
if !lastDash && builder.Len() > 0 {
builder.WriteRune('-')
lastDash = true
}
}
}
result := strings.Trim(builder.String(), "-")
if result == "" {
return "my-mcp"
}
return result
}
func sanitizeModuleSegment(binaryName string) string {
segment := sanitizeSlug(binaryName)
if segment == "" {
return "my-mcp"
}
return segment
}
func environmentPrefix(binaryName string) string {
name := strings.ToUpper(strings.TrimSpace(binaryName))
if name == "" {
return "MCP"
}
var builder strings.Builder
lastUnderscore := false
for _, r := range name {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
builder.WriteRune(r)
lastUnderscore = false
default:
if !lastUnderscore {
builder.WriteRune('_')
lastUnderscore = true
}
}
}
result := strings.Trim(builder.String(), "_")
if result == "" {
return "MCP"
}
return result
}
const gitignoreTemplate = `bin/
dist/
*.log
`
const goModTemplate = `module {{.ModulePath}}
go 1.25.0
`
const mainTemplate = `package main
import (
"context"
"log"
"os"
"{{.ModulePath}}/internal/app"
)
var version = "dev"
func main() {
if err := app.Run(context.Background(), os.Args[1:], version); err != nil {
log.Fatal(err)
}
}
`
const appTemplate = `package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"gitea.lclr.dev/AI/mcp-framework/bootstrap"
"gitea.lclr.dev/AI/mcp-framework/cli"
"gitea.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
"gitea.lclr.dev/AI/mcp-framework/update"
)
type Profile struct {
BaseURL string
}
type Runtime struct {
ConfigStore config.Store[Profile]
Manifest manifest.File
BinaryName string
Description string
Version string
DefaultProfile string
ProfileEnv string
TokenEnv string
SecretName string
SecretStorePolicy string
}
func Run(ctx context.Context, args []string, version string) error {
runtime, err := NewRuntime(version)
if err != nil {
return err
}
return runtime.Run(ctx, args)
}
func NewRuntime(version string) (Runtime, error) {
manifestFile, _, err := manifest.LoadDefault(".")
if err != nil {
return Runtime{}, err
}
bootstrapInfo := manifestFile.BootstrapInfo()
scaffoldInfo := manifestFile.ScaffoldInfo()
binaryName := firstNonEmpty(bootstrapInfo.BinaryName, "{{.BinaryName}}")
description := firstNonEmpty(bootstrapInfo.Description, "{{.Description}}")
defaultProfile := firstNonEmpty(scaffoldInfo.DefaultProfile, "{{.DefaultProfile}}")
profileEnv := "{{.ProfileEnv}}"
tokenEnv := "{{.TokenEnv}}"
if len(scaffoldInfo.KnownEnvironmentVariables) > 0 {
profileEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[0], profileEnv)
}
if len(scaffoldInfo.KnownEnvironmentVariables) > 2 {
tokenEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[2], tokenEnv)
}
return Runtime{
ConfigStore: config.NewStore[Profile](binaryName),
Manifest: manifestFile,
BinaryName: binaryName,
Description: description,
Version: firstNonEmpty(strings.TrimSpace(version), "dev"),
DefaultProfile: defaultProfile,
ProfileEnv: profileEnv,
TokenEnv: tokenEnv,
SecretName: binaryName + "-api-token",
SecretStorePolicy: firstNonEmpty(scaffoldInfo.SecretStorePolicy, "{{.SecretStorePolicy}}"),
}, nil
}
func (r Runtime) Run(ctx context.Context, args []string) error {
return bootstrap.Run(ctx, bootstrap.Options{
BinaryName: r.BinaryName,
Description: r.Description,
Version: r.Version,
Args: args,
Hooks: bootstrap.Hooks{
Setup: r.runSetup,
MCP: r.runMCP,
ConfigShow: r.runConfigShow,
ConfigTest: r.runConfigTest,
Update: r.runUpdate,
},
})
}
func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error {
stdin, ok := inv.Stdin.(*os.File)
if !ok || stdin == nil {
stdin = os.Stdin
}
stdout := inv.Stdout
if stdout == nil {
stdout = os.Stdout
}
cfg, _, err := r.ConfigStore.LoadDefault()
if err != nil {
return err
}
profileName := r.resolveProfileName(cfg.CurrentProfile)
profile := cfg.Profiles[profileName]
storedToken, _ := r.readToken()
result, err := cli.RunSetup(cli.SetupOptions{
Stdin: stdin,
Stdout: stdout,
Fields: []cli.SetupField{
{
Name: "base_url",
Label: "Base URL",
Type: cli.SetupFieldURL,
Required: true,
Default: profile.BaseURL,
},
{
Name: "api_token",
Label: "API token",
Type: cli.SetupFieldSecret,
Required: true,
ExistingSecret: storedToken,
},
},
})
if err != nil {
return err
}
baseURLValue, _ := result.Get("base_url")
tokenValue, _ := result.Get("api_token")
profile.BaseURL = strings.TrimSpace(baseURLValue.String)
cfg.CurrentProfile = profileName
cfg.Profiles[profileName] = profile
if _, err := r.ConfigStore.SaveDefault(cfg); err != nil {
return err
}
if !tokenValue.KeptStoredSecret {
store, err := r.openSecretStore()
if err != nil {
return err
}
if err := store.SetSecret(r.SecretName, "API token", tokenValue.String); err != nil {
if errors.Is(err, secretstore.ErrReadOnly) {
fmt.Fprintf(stdout, "Secret store en lecture seule, exporte %s pour fournir le token.\n", r.TokenEnv)
} else {
return err
}
}
}
_, err = fmt.Fprintf(stdout, "Configuration sauvegardée pour le profil %q.\n", profileName)
return err
}
func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error {
stdout := inv.Stdout
if stdout == nil {
stdout = os.Stdout
}
cfg, _, err := r.ConfigStore.LoadDefault()
if err != nil {
return err
}
profileName := r.resolveProfileName(cfg.CurrentProfile)
profile, ok := cfg.Profiles[profileName]
if !ok {
return fmt.Errorf("profil %q absent, lance %s setup", profileName, r.BinaryName)
}
token, err := r.readToken()
if err != nil {
if errors.Is(err, secretstore.ErrNotFound) {
return fmt.Errorf("secret %q introuvable, lance %s setup", r.SecretName, r.BinaryName)
}
return err
}
fmt.Fprintf(stdout, "MCP prêt sur %s (profil %s).\n", profile.BaseURL, profileName)
fmt.Fprintf(stdout, "Token chargé (%d caractères).\n", len(strings.TrimSpace(token)))
fmt.Fprintln(stdout, "Ajoute ici ta logique métier MCP.")
return nil
}
func (r Runtime) runConfigShow(_ context.Context, inv bootstrap.Invocation) error {
stdout := inv.Stdout
if stdout == nil {
stdout = os.Stdout
}
cfg, path, err := r.ConfigStore.LoadDefault()
if err != nil {
return err
}
payload, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config JSON: %w", err)
}
if _, err := fmt.Fprintf(stdout, "Config: %s\n", path); err != nil {
return err
}
_, err = fmt.Fprintf(stdout, "%s\n", payload)
return err
}
func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) error {
stdout := inv.Stdout
if stdout == nil {
stdout = os.Stdout
}
report := cli.RunDoctor(ctx, cli.DoctorOptions{
ConfigCheck: cli.NewConfigCheck(r.ConfigStore),
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(r.openSecretStore),
RequiredSecrets: []cli.DoctorSecret{
{Name: r.SecretName, Label: "API token"},
},
SecretStoreFactory: r.openSecretStore,
ManifestDir: ".",
})
if err := cli.RenderDoctorReport(stdout, report); err != nil {
return err
}
if report.HasFailures() {
return errors.New("doctor checks failed")
}
return nil
}
func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error {
stdout := inv.Stdout
if stdout == nil {
stdout = os.Stdout
}
return update.Run(ctx, update.Options{
CurrentVersion: r.Version,
BinaryName: r.BinaryName,
ReleaseSource: r.Manifest.Update.ReleaseSource(),
Stdout: stdout,
})
}
func (r Runtime) openSecretStore() (secretstore.Store, error) {
return secretstore.Open(secretstore.Options{
ServiceName: r.BinaryName,
BackendPolicy: secretstore.BackendPolicy(r.SecretStorePolicy),
LookupEnv: func(name string) (string, bool) {
if name == r.SecretName {
return os.LookupEnv(r.TokenEnv)
}
return os.LookupEnv(name)
},
})
}
func (r Runtime) readToken() (string, error) {
store, err := r.openSecretStore()
if err != nil {
return "", err
}
return store.GetSecret(r.SecretName)
}
func (r Runtime) resolveProfileName(currentProfile string) string {
resolved := cli.ResolveProfileName("", os.Getenv(r.ProfileEnv), currentProfile)
if strings.TrimSpace(resolved) != "" {
return resolved
}
return r.DefaultProfile
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
`
const manifestTemplate = `binary_name = "{{.BinaryName}}"
docs_url = "{{.DocsURL}}"
[update]
source_name = "Release endpoint"
driver = "{{.ReleaseDriver}}"
repository = "{{.ReleaseRepository}}"
base_url = "{{.ReleaseBaseURL}}"
asset_name_template = "{binary}-{os}-{arch}{ext}"
checksum_asset_name = "{asset}.sha256"
checksum_required = false
token_header = "Authorization"
token_prefix = "token"
token_env_names = ["{{.ReleaseTokenEnv}}"]
[environment]
known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}]
[secret_store]
backend_policy = "{{.SecretStorePolicy}}"
[profiles]
default = "{{.DefaultProfile}}"
known = [{{- range $index, $value := .Profiles}}{{if $index}}, {{end}}"{{$value}}"{{- end}}]
[bootstrap]
description = "{{.Description}}"
`
const readmeTemplate = `# {{.BinaryName}}
Binaire MCP généré depuis ` + "`mcp-framework`" + `.
## Arborescence générée
` + "```text" + `
.
cmd/
{{.BinaryName}}/
main.go
internal/
app/
app.go
.gitignore
go.mod
mcp.toml
README.md
` + "```" + `
## Démarrage rapide
1. Installer les dépendances :
` + "```bash" + `
go mod tidy
` + "```" + `
2. Vérifier laide CLI bootstrap :
` + "```bash" + `
go run ./cmd/{{.BinaryName}} help
` + "```" + `
3. Initialiser la configuration locale :
` + "```bash" + `
go run ./cmd/{{.BinaryName}} setup
` + "```" + `
4. Lancer le flux MCP (placeholder) :
` + "```bash" + `
go run ./cmd/{{.BinaryName}} mcp
` + "```" + `
5. Vérifier la configuration et le manifeste :
` + "```bash" + `
go run ./cmd/{{.BinaryName}} config test
` + "```" + `
## Points à adapter
- Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs).
- Compléter la logique métier dans ` + "`internal/app/app.go`" + ` (` + "`runMCP`" + `).
- Ajuster les variables denvironnement connues si besoin.
`

183
scaffold/scaffold_test.go Normal file
View file

@ -0,0 +1,183 @@
package scaffold
import (
"errors"
"go/parser"
"go/token"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
target := filepath.Join(t.TempDir(), "my-mcp")
result, err := Generate(Options{
TargetDir: target,
ModulePath: "example.com/acme/my-mcp",
BinaryName: "my-mcp",
Description: "Client MCP interne",
DefaultProfile: "prod",
Profiles: []string{"dev", "prod"},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if result.Root != target {
t.Fatalf("result root = %q, want %q", result.Root, target)
}
wantFiles := []string{
".gitignore",
"README.md",
"cmd/my-mcp/main.go",
"go.mod",
"internal/app/app.go",
"mcp.toml",
}
if !slices.Equal(result.Files, wantFiles) {
t.Fatalf("result files = %v, want %v", result.Files, wantFiles)
}
for _, path := range wantFiles {
if _, err := os.Stat(filepath.Join(target, filepath.FromSlash(path))); err != nil {
t.Fatalf("generated file %q missing: %v", path, err)
}
}
mainGo, err := os.ReadFile(filepath.Join(target, "cmd", "my-mcp", "main.go"))
if err != nil {
t.Fatalf("ReadFile main.go: %v", err)
}
if !strings.Contains(string(mainGo), "\"example.com/acme/my-mcp/internal/app\"") {
t.Fatalf("main.go does not import internal app package")
}
if _, err := parser.ParseFile(token.NewFileSet(), "main.go", mainGo, parser.AllErrors); err != nil {
t.Fatalf("generated main.go is invalid Go: %v", err)
}
appGo, err := os.ReadFile(filepath.Join(target, "internal", "app", "app.go"))
if err != nil {
t.Fatalf("ReadFile app.go: %v", err)
}
for _, snippet := range []string{
"config.NewStore[Profile]",
"secretstore.Open",
"update.Run",
"manifest.LoadDefault",
"bootstrap.Run",
} {
if !strings.Contains(string(appGo), snippet) {
t.Fatalf("app.go missing snippet %q", snippet)
}
}
if _, err := parser.ParseFile(token.NewFileSet(), "app.go", appGo, parser.AllErrors); err != nil {
t.Fatalf("generated app.go is invalid Go: %v", err)
}
manifestContent, err := os.ReadFile(filepath.Join(target, "mcp.toml"))
if err != nil {
t.Fatalf("ReadFile mcp.toml: %v", err)
}
for _, snippet := range []string{
"binary_name = \"my-mcp\"",
"[update]",
"[secret_store]",
"[environment]",
"[profiles]",
"backend_policy = \"auto\"",
} {
if !strings.Contains(string(manifestContent), snippet) {
t.Fatalf("mcp.toml missing snippet %q", snippet)
}
}
readme, err := os.ReadFile(filepath.Join(target, "README.md"))
if err != nil {
t.Fatalf("ReadFile README.md: %v", err)
}
for _, snippet := range []string{
"Arborescence générée",
"go run ./cmd/my-mcp setup",
"internal/app/app.go",
} {
if !strings.Contains(string(readme), snippet) {
t.Fatalf("README missing snippet %q", snippet)
}
}
}
func TestGenerateUsesDefaultsFromTargetDirectory(t *testing.T) {
target := filepath.Join(t.TempDir(), "super-agent-mcp")
_, err := Generate(Options{TargetDir: target})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
goModContent, err := os.ReadFile(filepath.Join(target, "go.mod"))
if err != nil {
t.Fatalf("ReadFile go.mod: %v", err)
}
if !strings.Contains(string(goModContent), "module example.com/super-agent-mcp") {
t.Fatalf("go.mod should contain default module path")
}
manifestContent, err := os.ReadFile(filepath.Join(target, "mcp.toml"))
if err != nil {
t.Fatalf("ReadFile mcp.toml: %v", err)
}
for _, snippet := range []string{
"binary_name = \"super-agent-mcp\"",
"SUPER_AGENT_MCP_PROFILE",
"SUPER_AGENT_MCP_API_TOKEN",
} {
if !strings.Contains(string(manifestContent), snippet) {
t.Fatalf("mcp.toml missing snippet %q", snippet)
}
}
}
func TestGenerateFailsWhenFileAlreadyExistsWithoutOverwrite(t *testing.T) {
target := t.TempDir()
readmePath := filepath.Join(target, "README.md")
if err := os.WriteFile(readmePath, []byte("pre-existing"), 0o644); err != nil {
t.Fatalf("WriteFile README.md: %v", err)
}
_, err := Generate(Options{TargetDir: target})
if !errors.Is(err, ErrFileExists) {
t.Fatalf("Generate error = %v, want ErrFileExists", err)
}
}
func TestGenerateOverwritesExistingFilesWhenRequested(t *testing.T) {
target := t.TempDir()
readmePath := filepath.Join(target, "README.md")
if err := os.WriteFile(readmePath, []byte("pre-existing"), 0o644); err != nil {
t.Fatalf("WriteFile README.md: %v", err)
}
_, err := Generate(Options{TargetDir: target, Overwrite: true})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
readmeContent, err := os.ReadFile(readmePath)
if err != nil {
t.Fatalf("ReadFile README.md: %v", err)
}
if !strings.Contains(string(readmeContent), "Démarrage rapide") {
t.Fatalf("README should be overwritten with scaffold content")
}
}
func TestGenerateRequiresTargetDirectory(t *testing.T) {
_, err := Generate(Options{})
if !errors.Is(err, ErrTargetDirRequired) {
t.Fatalf("Generate error = %v, want ErrTargetDirRequired", err)
}
}