feat: add MCP scaffold generator #20
3 changed files with 322 additions and 0 deletions
21
README.md
21
README.md
|
|
@ -17,6 +17,27 @@ 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.
|
||||
|
|
|
|||
200
cmd/mcp-framework/main.go
Normal file
200
cmd/mcp-framework/main.go
Normal 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
|
||||
}
|
||||
101
cmd/mcp-framework/main_test.go
Normal file
101
cmd/mcp-framework/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue