mcp-framework/scaffold/scaffold.go

735 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
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",
}, 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.OpenFromManifest(secretstore.OpenFromManifestOptions{
ServiceName: r.BinaryName,
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.
`