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
|
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
|
## Packages
|
||||||
|
|
||||||
- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites.
|
- `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