feat: add unified build command driven by mcp.toml

This commit is contained in:
thibaud-lclr 2026-04-14 18:02:07 +02:00
parent 0e5bfb2d39
commit 845d20541b
9 changed files with 464 additions and 1 deletions

View file

@ -38,6 +38,36 @@ go mod tidy
go run ./cmd/my-mcp help
```
## CLI de build unifiée
Le binaire `mcp-framework` expose aussi une commande de build standardisée pour
les projets consommateurs :
```bash
go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build
```
Par défaut la commande :
- lit `mcp.toml` (en remontant les répertoires parents)
- récupère `binary_name`
- build `./cmd/<binary_name>`
- produit `build/<binary>-<goos>-<goarch>{.exe}`
- injecte la version via `-X main.version=<version>`
(`VERSION` env, sinon `git describe`, sinon `dev`)
Options principales :
- `--manifest-dir`
- `--binary`
- `--package`
- `--build-dir`
- `--goos`
- `--goarch`
- `--version`
- `--version-var`
- `--ldflag` (répétable)
## Packages
- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites.
@ -150,6 +180,7 @@ Champs supportés :
- `binary_name` : nom du binaire (utilisable par le bootstrap/scaffolding).
- `docs_url` : URL de documentation projet.
- `[update]` : source de release consommée par `update`.
- `[build]` : paramètres de build pour `mcp-framework build`.
- `source_name` : nom humain de la source de release, utilisé dans certains messages d'erreur.
- `driver` : driver de forge (`gitea`, `gitlab`, `github`) pour déduire automatiquement l'endpoint latest.
@ -162,6 +193,9 @@ Champs supportés :
- `token_header` : header HTTP à utiliser pour l'authentification.
- `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...).
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
- `[build].main_package` : package main à builder (ex: `./cmd/my-mcp`).
- `[build].output_dir` : répertoire de sortie des artefacts (défaut `build`).
- `[build].version_var` : variable Go ciblée par `-X` pour injecter la version (`main.version`, `gitlab.../internal/app.Version`, etc.).
- `[environment].known` : variables d'environnement connues du projet.
- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`).
- `[profiles].default` : profil recommandé par défaut.

234
cmd/mcp-framework/build.go Normal file
View file

@ -0,0 +1,234 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
manifestpkg "gitea.lclr.dev/AI/mcp-framework/manifest"
)
type stringListFlag []string
func (f *stringListFlag) String() string {
return strings.Join(*f, ",")
}
func (f *stringListFlag) Set(value string) error {
*f = append(*f, strings.TrimSpace(value))
return nil
}
func runBuild(args []string, stdout, stderr io.Writer) error {
if shouldShowHelp(args) {
printBuildHelp(stdout)
return nil
}
fs := flag.NewFlagSet("build", flag.ContinueOnError)
fs.SetOutput(io.Discard)
defaultGOOS := strings.TrimSpace(os.Getenv("GOOS"))
if defaultGOOS == "" {
defaultGOOS = runtime.GOOS
}
defaultGOARCH := strings.TrimSpace(os.Getenv("GOARCH"))
if defaultGOARCH == "" {
defaultGOARCH = runtime.GOARCH
}
var manifestDir string
var binaryName string
var mainPackage string
var buildDir string
var goos string
var goarch string
var version string
var versionVar string
var gocache string
var extraLDFlags stringListFlag
fs.StringVar(&manifestDir, "manifest-dir", ".", "Répertoire de départ pour trouver mcp.toml")
fs.StringVar(&binaryName, "binary", "", "Nom du binaire (override binary_name)")
fs.StringVar(&mainPackage, "package", "", "Package main à builder (override [build].main_package)")
fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)")
fs.StringVar(&goos, "goos", defaultGOOS, "GOOS cible")
fs.StringVar(&goarch, "goarch", defaultGOARCH, "GOARCH cible")
fs.StringVar(&version, "version", "", "Version injectée (par défaut VERSION env, puis git describe, puis dev)")
fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)")
fs.StringVar(&gocache, "gocache", strings.TrimSpace(os.Getenv("GOCACHE")), "Valeur GOCACHE pour go build")
fs.Var(&extraLDFlags, "ldflag", "Option additionnelle passée à -ldflags (répéter si nécessaire)")
if err := fs.Parse(args); err != nil {
_ = stderr
return fmt.Errorf("parse build flags: %w", err)
}
if fs.NArg() > 0 {
return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", "))
}
file, manifestPath, err := manifestpkg.LoadDefault(manifestDir)
if err != nil {
return err
}
projectDir := filepath.Dir(manifestPath)
binaryName = firstNonEmpty(binaryName, file.BinaryName)
if binaryName == "" {
return errors.New("binary name is required (set binary_name in mcp.toml or --binary)")
}
mainPackage = firstNonEmpty(mainPackage, file.Build.MainPackage)
if mainPackage == "" {
mainPackage = fmt.Sprintf("./cmd/%s", binaryName)
}
buildDir = firstNonEmpty(buildDir, file.Build.OutputDir, "build")
goos = firstNonEmpty(goos, runtime.GOOS)
goarch = firstNonEmpty(goarch, runtime.GOARCH)
version = resolveBuildVersion(version, projectDir)
versionVar = firstNonEmpty(versionVar, file.Build.VersionVar, "main.version")
outputPath, err := buildOutputPath(projectDir, buildDir, binaryName, goos, goarch)
if err != nil {
return err
}
outputDir := filepath.Dir(outputPath)
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return fmt.Errorf("create build directory %q: %w", outputDir, err)
}
ldflags := normalizeStringList(extraLDFlags)
if versionVar != "-" {
versionVar = strings.TrimSpace(versionVar)
if versionVar != "" {
ldflags = append([]string{fmt.Sprintf("-X %s=%s", versionVar, version)}, ldflags...)
}
}
cmdArgs := []string{"build"}
if len(ldflags) > 0 {
cmdArgs = append(cmdArgs, "-ldflags", strings.Join(ldflags, " "))
}
cmdArgs = append(cmdArgs, "-o", outputPath, mainPackage)
cmd := exec.Command("go", cmdArgs...)
cmd.Dir = projectDir
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Env = withEnvOverrides(os.Environ(), map[string]string{
"GOOS": goos,
"GOARCH": goarch,
"GOCACHE": strings.TrimSpace(gocache),
})
if err := cmd.Run(); err != nil {
return fmt.Errorf("go build failed: %w", err)
}
if _, err := fmt.Fprintf(stdout, "Build artifact: %s\n", outputPath); err != nil {
return err
}
return nil
}
func printBuildHelp(w io.Writer) {
fmt.Fprintf(
w,
"Usage:\n %s build [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nManifest optional ([build]):\n main_package = \"./cmd/<binary>\"\n output_dir = \"build\"\n version_var = \"main.version\"\n",
toolName,
)
}
func resolveBuildVersion(explicit, projectDir string) string {
if value := strings.TrimSpace(explicit); value != "" {
return value
}
if value := strings.TrimSpace(os.Getenv("VERSION")); value != "" {
return value
}
describe := exec.Command("git", "describe", "--tags", "--always", "--dirty")
describe.Dir = projectDir
out, err := describe.Output()
if err == nil {
if value := strings.TrimSpace(string(out)); value != "" {
return value
}
}
return "dev"
}
func buildOutputPath(projectDir, buildDir, binaryName, goos, goarch string) (string, error) {
artifactName := fmt.Sprintf("%s-%s-%s", binaryName, goos, goarch)
if goos == "windows" {
artifactName += ".exe"
}
dir := strings.TrimSpace(buildDir)
if dir == "" {
dir = "build"
}
if filepath.IsAbs(dir) {
return filepath.Join(dir, artifactName), nil
}
if strings.TrimSpace(projectDir) == "" {
return "", errors.New("project directory is required")
}
return filepath.Join(projectDir, dir, artifactName), nil
}
func withEnvOverrides(base []string, overrides map[string]string) []string {
result := make([]string, 0, len(base)+len(overrides))
for _, entry := range base {
key, _, found := strings.Cut(entry, "=")
if !found {
continue
}
if _, overridden := overrides[key]; overridden {
continue
}
result = append(result, entry)
}
for key, value := range overrides {
if strings.TrimSpace(value) == "" {
continue
}
result = append(result, key+"="+value)
}
return result
}
func normalizeStringList(values []string) []string {
normalized := make([]string, 0, len(values))
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
normalized = append(normalized, trimmed)
}
}
return normalized
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

View file

@ -0,0 +1,155 @@
package main
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestRunBuildBuildsArtifactFromManifest(t *testing.T) {
projectDir := t.TempDir()
writeBuildFixture(t, projectDir, "demo-mcp", `
module example.com/demo
go 1.25.0
`, `
binary_name = "demo-mcp"
[build]
main_package = "./cmd/demo-mcp"
output_dir = "build"
version_var = "main.version"
`, `
package main
import "fmt"
var version = "dev"
func main() {
fmt.Print(version)
}
`)
var stdout bytes.Buffer
var stderr bytes.Buffer
err := run([]string{
"build",
"--manifest-dir", projectDir,
"--goos", runtime.GOOS,
"--goarch", runtime.GOARCH,
"--version", "1.2.3",
}, &stdout, &stderr)
if err != nil {
t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String())
}
artifactPath := artifactPath(projectDir, "build", "demo-mcp", runtime.GOOS, runtime.GOARCH)
if _, err := os.Stat(artifactPath); err != nil {
t.Fatalf("expected artifact at %s: %v", artifactPath, err)
}
output, err := exec.Command(artifactPath).Output()
if err != nil {
t.Fatalf("run artifact: %v", err)
}
if strings.TrimSpace(string(output)) != "1.2.3" {
t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "1.2.3")
}
}
func TestRunBuildUsesManifestVersionVar(t *testing.T) {
projectDir := t.TempDir()
writeBuildFixture(t, projectDir, "custom-version", `
module example.com/custom-version
go 1.25.0
`, `
binary_name = "custom-version"
[build]
main_package = "./cmd/custom-version"
output_dir = "dist"
version_var = "main.releaseVersion"
`, `
package main
import "fmt"
var releaseVersion = "dev"
func main() {
fmt.Print(releaseVersion)
}
`)
var stdout bytes.Buffer
var stderr bytes.Buffer
err := run([]string{
"build",
"--manifest-dir", projectDir,
"--goos", runtime.GOOS,
"--goarch", runtime.GOARCH,
"--version", "9.9.9",
}, &stdout, &stderr)
if err != nil {
t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String())
}
artifactPath := artifactPath(projectDir, "dist", "custom-version", runtime.GOOS, runtime.GOARCH)
output, err := exec.Command(artifactPath).Output()
if err != nil {
t.Fatalf("run artifact: %v", err)
}
if strings.TrimSpace(string(output)) != "9.9.9" {
t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "9.9.9")
}
}
func TestBuildHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := run([]string{"build", "--help"}, &stdout, &stderr); err != nil {
t.Fatalf("run returned error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "--manifest-dir") {
t.Fatalf("build help should mention --manifest-dir: %q", output)
}
if !strings.Contains(output, "version_var") {
t.Fatalf("build help should mention manifest build config: %q", output)
}
}
func writeBuildFixture(t *testing.T, projectDir, binaryName, goModContent, manifestContent, mainContent string) {
t.Helper()
if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(strings.TrimSpace(goModContent)+"\n"), 0o644); err != nil {
t.Fatalf("write go.mod: %v", err)
}
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(strings.TrimSpace(manifestContent)+"\n"), 0o644); err != nil {
t.Fatalf("write mcp.toml: %v", err)
}
cmdDir := filepath.Join(projectDir, "cmd", binaryName)
if err := os.MkdirAll(cmdDir, 0o755); err != nil {
t.Fatalf("mkdir cmd dir: %v", err)
}
if err := os.WriteFile(filepath.Join(cmdDir, "main.go"), []byte(strings.TrimSpace(mainContent)+"\n"), 0o644); err != nil {
t.Fatalf("write main.go: %v", err)
}
}
func artifactPath(projectDir, outDir, binaryName, goos, goarch string) string {
name := binaryName + "-" + goos + "-" + goarch
if goos == "windows" {
name += ".exe"
}
return filepath.Join(projectDir, outDir, name)
}

View file

@ -34,6 +34,8 @@ func run(args []string, stdout, stderr io.Writer) error {
}
switch args[0] {
case "build":
return runBuild(args[1:], stdout, stderr)
case "scaffold":
return runScaffold(args[1:], stdout, stderr)
default:
@ -142,7 +144,7 @@ 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 build Build un binaire MCP de manière standardisée\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n",
toolName,
toolName,
)

View file

@ -23,6 +23,9 @@ func TestRunPrintsGlobalHelp(t *testing.T) {
if !strings.Contains(output, "scaffold init") {
t.Fatalf("global help should mention scaffold init: %q", output)
}
if !strings.Contains(output, "build") {
t.Fatalf("global help should mention build: %q", output)
}
}
func TestRunScaffoldInitCreatesProject(t *testing.T) {

View file

@ -18,6 +18,7 @@ type File struct {
BinaryName string `toml:"binary_name"`
DocsURL string `toml:"docs_url"`
Update Update `toml:"update"`
Build Build `toml:"build"`
Environment Environment `toml:"environment"`
SecretStore SecretStore `toml:"secret_store"`
Profiles Profiles `toml:"profiles"`
@ -38,6 +39,12 @@ type Update struct {
TokenEnvNames []string `toml:"token_env_names"`
}
type Build struct {
MainPackage string `toml:"main_package"`
OutputDir string `toml:"output_dir"`
VersionVar string `toml:"version_var"`
}
type Environment struct {
Known []string `toml:"known"`
}
@ -141,6 +148,7 @@ func (f *File) normalize() {
f.BinaryName = strings.TrimSpace(f.BinaryName)
f.DocsURL = strings.TrimSpace(f.DocsURL)
f.Update.normalize()
f.Build.normalize()
f.Environment.normalize()
f.SecretStore.normalize()
f.Profiles.normalize()
@ -160,6 +168,12 @@ func (u *Update) normalize() {
u.TokenEnvNames = normalizeStringList(u.TokenEnvNames)
}
func (b *Build) normalize() {
b.MainPackage = strings.TrimSpace(b.MainPackage)
b.OutputDir = strings.TrimSpace(b.OutputDir)
b.VersionVar = strings.TrimSpace(b.VersionVar)
}
func (e *Environment) normalize() {
e.Known = normalizeStringList(e.Known)
}

View file

@ -151,6 +151,11 @@ docs_url = " https://docs.example.com/mcp "
[update]
latest_release_url = "https://example.com/latest"
[build]
main_package = " ./cmd/my-mcp "
output_dir = " build "
version_var = " main.version "
[environment]
known = [" MCP_PROFILE ", "", "MCP_TOKEN"]
@ -183,6 +188,15 @@ description = " Client MCP interne "
if !slices.Equal(file.Environment.Known, []string{"MCP_PROFILE", "MCP_TOKEN"}) {
t.Fatalf("environment known = %v", file.Environment.Known)
}
if file.Build.MainPackage != "./cmd/my-mcp" {
t.Fatalf("build main package = %q", file.Build.MainPackage)
}
if file.Build.OutputDir != "build" {
t.Fatalf("build output dir = %q", file.Build.OutputDir)
}
if file.Build.VersionVar != "main.version" {
t.Fatalf("build version var = %q", file.Build.VersionVar)
}
if file.SecretStore.BackendPolicy != "auto" {
t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy)
}

View file

@ -661,6 +661,11 @@ token_header = "Authorization"
token_prefix = "token"
token_env_names = ["{{.ReleaseTokenEnv}}"]
[build]
main_package = "./cmd/{{.BinaryName}}"
output_dir = "build"
version_var = "main.version"
[environment]
known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}]

View file

@ -85,9 +85,11 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
for _, snippet := range []string{
"binary_name = \"my-mcp\"",
"[update]",
"[build]",
"[secret_store]",
"[environment]",
"[profiles]",
"version_var = \"main.version\"",
"backend_policy = \"auto\"",
} {
if !strings.Contains(string(manifestContent), snippet) {