mcp-framework/generate/generate.go

618 lines
16 KiB
Go
Raw Permalink Normal View History

2026-05-02 09:46:47 +00:00
package generate
import (
"bytes"
"errors"
"fmt"
"go/format"
"go/token"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
2026-05-05 10:23:14 +00:00
"forge.lclr.dev/AI/mcp-framework/manifest"
2026-05-02 09:46:47 +00:00
)
var ErrGeneratedFilesOutdated = errors.New("generated files are not up to date")
type Options struct {
ProjectDir string
ManifestPath string
PackageDir string
PackageName string
Check bool
}
type Result struct {
Root string
Files []string
}
func Generate(options Options) (Result, error) {
normalized, err := normalizeOptions(options)
if err != nil {
return Result{}, err
}
2026-05-02 09:57:44 +00:00
manifestFile, err := manifest.Load(normalized.ManifestPath)
if err != nil {
2026-05-02 09:46:47 +00:00
return Result{}, err
}
manifestContent, err := os.ReadFile(normalized.ManifestPath)
if err != nil {
return Result{}, fmt.Errorf("read manifest %s: %w", normalized.ManifestPath, err)
}
2026-05-02 09:57:44 +00:00
manifestLoader, err := renderManifestLoader(normalized.PackageName, string(manifestContent))
if err != nil {
return Result{}, err
}
metadata, err := renderMetadata(normalized.PackageName, manifestFile)
if err != nil {
return Result{}, err
}
update, err := renderUpdate(normalized.PackageName)
if err != nil {
return Result{}, err
}
secretstore, err := renderSecretStore(normalized.PackageName)
2026-05-02 09:46:47 +00:00
if err != nil {
return Result{}, err
}
2026-05-02 10:02:23 +00:00
config, err := renderConfig(normalized.PackageName, manifestFile.Config.Fields)
if err != nil {
return Result{}, err
}
2026-05-02 09:46:47 +00:00
files := []generatedFile{
{
Path: filepath.Join(normalized.PackageDir, "manifest.go"),
2026-05-02 09:57:44 +00:00
Content: manifestLoader,
Mode: 0o644,
},
{
Path: filepath.Join(normalized.PackageDir, "metadata.go"),
Content: metadata,
Mode: 0o644,
},
{
Path: filepath.Join(normalized.PackageDir, "update.go"),
Content: update,
Mode: 0o644,
},
{
Path: filepath.Join(normalized.PackageDir, "secretstore.go"),
Content: secretstore,
2026-05-02 09:46:47 +00:00
Mode: 0o644,
},
}
2026-05-02 10:02:23 +00:00
if strings.TrimSpace(config) != "" {
files = append(files, generatedFile{
Path: filepath.Join(normalized.PackageDir, "config.go"),
Content: config,
Mode: 0o644,
})
}
2026-05-02 09:46:47 +00:00
written := make([]string, 0, len(files))
for _, file := range files {
target := filepath.Join(normalized.ProjectDir, file.Path)
if normalized.Check {
current, err := os.ReadFile(target)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path)
}
return Result{}, fmt.Errorf("read generated file %q: %w", target, err)
}
if !bytes.Equal(current, []byte(file.Content)) {
return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path)
}
written = append(written, file.Path)
continue
}
if err := writeGeneratedFile(target, file.Content, file.Mode); err != nil {
return Result{}, err
}
written = append(written, file.Path)
}
sort.Strings(written)
return Result{
Root: normalized.ProjectDir,
Files: written,
}, nil
}
type normalizedOptions struct {
ProjectDir string
ManifestPath string
PackageDir string
PackageName string
Check bool
}
type generatedFile struct {
Path string
Content string
Mode os.FileMode
}
func normalizeOptions(options Options) (normalizedOptions, error) {
manifestPath := strings.TrimSpace(options.ManifestPath)
projectDir := strings.TrimSpace(options.ProjectDir)
if manifestPath == "" {
baseDir := projectDir
if baseDir == "" {
wd, err := os.Getwd()
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err)
}
baseDir = wd
}
manifestPath = filepath.Join(baseDir, manifest.DefaultFile)
} else if !filepath.IsAbs(manifestPath) {
baseDir := projectDir
if baseDir == "" {
wd, err := os.Getwd()
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err)
}
baseDir = wd
}
manifestPath = filepath.Join(baseDir, manifestPath)
}
resolvedManifest, err := filepath.Abs(manifestPath)
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve manifest path %q: %w", manifestPath, err)
}
if projectDir == "" {
projectDir = filepath.Dir(resolvedManifest)
}
resolvedProjectDir, err := filepath.Abs(projectDir)
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve project dir %q: %w", projectDir, err)
}
packageDir := filepath.Clean(strings.TrimSpace(options.PackageDir))
if packageDir == "." || packageDir == "" {
packageDir = "mcpgen"
}
if filepath.IsAbs(packageDir) || packageDir == ".." || strings.HasPrefix(packageDir, ".."+string(filepath.Separator)) {
return normalizedOptions{}, fmt.Errorf("package dir %q must be relative to the project", options.PackageDir)
}
packageName := strings.TrimSpace(options.PackageName)
if packageName == "" {
packageName = filepath.Base(packageDir)
}
if !token.IsIdentifier(packageName) {
return normalizedOptions{}, fmt.Errorf("package name %q is not a valid Go identifier", packageName)
}
return normalizedOptions{
ProjectDir: resolvedProjectDir,
ManifestPath: resolvedManifest,
PackageDir: packageDir,
PackageName: packageName,
Check: options.Check,
}, nil
}
func renderManifestLoader(packageName, manifestContent string) (string, error) {
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
2026-05-05 10:23:14 +00:00
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
2026-05-02 09:46:47 +00:00
const embeddedManifest = %s
func LoadManifest(startDir string) (fwmanifest.File, string, error) {
return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)
}
`, packageName, strconv.Quote(manifestContent))
formatted, err := format.Source([]byte(source))
if err != nil {
return "", fmt.Errorf("format generated manifest loader: %w", err)
}
return string(formatted), nil
}
2026-05-02 09:57:44 +00:00
func renderMetadata(packageName string, manifestFile manifest.File) (string, error) {
bootstrapInfo := manifestFile.BootstrapInfo()
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
2026-05-05 10:23:14 +00:00
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
2026-05-02 09:57:44 +00:00
const BinaryName = %s
const DefaultDescription = %s
const DocsURL = %s
func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) {
manifestFile, source, err := LoadManifest(startDir)
if err != nil {
return fwmanifest.BootstrapMetadata{}, "", err
}
return manifestFile.BootstrapInfo(), source, nil
}
func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) {
manifestFile, source, err := LoadManifest(startDir)
if err != nil {
return fwmanifest.ScaffoldMetadata{}, "", err
}
return manifestFile.ScaffoldInfo(), source, nil
}
`, packageName, strconv.Quote(manifestFile.BinaryName), strconv.Quote(bootstrapInfo.Description), strconv.Quote(manifestFile.DocsURL))
return formatGenerated("metadata", source)
}
func renderUpdate(packageName string) (string, error) {
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
import (
"context"
"flag"
"fmt"
"io"
"strings"
2026-05-05 10:23:14 +00:00
fwupdate "forge.lclr.dev/AI/mcp-framework/update"
2026-05-02 09:57:44 +00:00
)
func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) {
return UpdateOptionsFrom(".", version, stdout)
}
func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) {
manifestFile, _, err := LoadManifest(startDir)
if err != nil {
return fwupdate.Options{}, err
}
binaryName := strings.TrimSpace(manifestFile.BinaryName)
if binaryName == "" {
binaryName = BinaryName
}
return fwupdate.Options{
CurrentVersion: version,
Stdout: stdout,
BinaryName: binaryName,
ReleaseSource: manifestFile.Update.ReleaseSource(),
}, nil
}
func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error {
return RunUpdateFrom(ctx, args, ".", version, stdout)
}
func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error {
fs := flag.NewFlagSet("update", flag.ContinueOnError)
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 0 {
return fmt.Errorf("update does not accept positional arguments: %%s", strings.Join(fs.Args(), ", "))
}
options, err := UpdateOptionsFrom(startDir, version, stdout)
if err != nil {
return err
}
return fwupdate.Run(ctx, options)
}
`, packageName)
return formatGenerated("update", source)
}
func renderSecretStore(packageName string) (string, error) {
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
import (
"os"
"path/filepath"
"strings"
2026-05-05 10:23:14 +00:00
fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
2026-05-02 09:57:44 +00:00
)
type SecretStoreOptions struct {
ServiceName string
LookupEnv func(string) (string, bool)
KWalletAppID string
KWalletFolder string
BitwardenCommand string
BitwardenDebug bool
DisableBitwardenCache bool
Shell string
ExecutableResolver fwsecretstore.ExecutableResolver
2026-05-02 09:57:44 +00:00
}
func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) {
return fwsecretstore.OpenFromManifest(secretStoreOpenOptions(options))
}
func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) {
return fwsecretstore.DescribeRuntime(secretStoreDescribeOptions(options))
}
func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) {
return fwsecretstore.PreflightFromManifest(secretStoreDescribeOptions(options))
}
func secretStoreOpenOptions(options SecretStoreOptions) fwsecretstore.OpenFromManifestOptions {
return fwsecretstore.OpenFromManifestOptions{
ServiceName: secretStoreServiceName(options),
LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder,
BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug,
DisableBitwardenCache: options.DisableBitwardenCache,
2026-05-02 09:57:44 +00:00
Shell: options.Shell,
ManifestLoader: LoadManifest,
ExecutableResolver: options.ExecutableResolver,
}
}
func secretStoreDescribeOptions(options SecretStoreOptions) fwsecretstore.DescribeRuntimeOptions {
return fwsecretstore.DescribeRuntimeOptions{
ServiceName: secretStoreServiceName(options),
LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder,
BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug,
DisableBitwardenCache: options.DisableBitwardenCache,
2026-05-02 09:57:44 +00:00
Shell: options.Shell,
ManifestLoader: LoadManifest,
ExecutableResolver: options.ExecutableResolver,
}
}
func secretStoreServiceName(options SecretStoreOptions) string {
serviceName := strings.TrimSpace(options.ServiceName)
if serviceName != "" {
return serviceName
}
startDir := "."
executableResolver := options.ExecutableResolver
if executableResolver == nil {
executableResolver = os.Executable
}
if executablePath, err := executableResolver(); err == nil {
if dir := strings.TrimSpace(filepath.Dir(strings.TrimSpace(executablePath))); dir != "" {
startDir = dir
}
}
if manifestFile, _, err := LoadManifest(startDir); err == nil {
if binaryName := strings.TrimSpace(manifestFile.BinaryName); binaryName != "" {
return binaryName
}
}
return BinaryName
}
`, packageName)
return formatGenerated("secretstore", source)
}
2026-05-02 10:02:23 +00:00
func renderConfig(packageName string, fields []manifest.ConfigField) (string, error) {
if len(fields) == 0 {
return "", nil
}
var flagsBuilder strings.Builder
var specsBuilder strings.Builder
var setupBuilder strings.Builder
for _, field := range fields {
name := strings.TrimSpace(field.Name)
if name == "" {
return "", fmt.Errorf("generate config field: name must not be empty")
}
flagName := strings.TrimSpace(field.Flag)
if flagName != "" {
fmt.Fprintf(
&flagsBuilder,
"\tflags.values[%s] = fs.String(%s, \"\", %s)\n",
strconv.Quote(name),
strconv.Quote(flagName),
strconv.Quote(configFieldLabel(field)),
)
}
fmt.Fprintf(
&specsBuilder,
"\t\t{Name: %s, Required: %t, DefaultValue: %s, Sources: []fwcli.ValueSource{%s}, FlagKey: %s, EnvKey: %s, ConfigKey: %s, SecretKey: replaceProfile(%s, profile)},\n",
strconv.Quote(name),
field.Required,
strconv.Quote(field.Default),
configSourceList(field.Sources),
strconv.Quote(flagName),
strconv.Quote(field.Env),
strconv.Quote(field.ConfigKey),
strconv.Quote(field.SecretKeyTemplate),
)
fmt.Fprintf(
&setupBuilder,
"\t\t{Name: %s, Label: %s, Type: %s, Required: %t, Default: %s, ExistingSecret: existing[%s]},\n",
strconv.Quote(name),
strconv.Quote(configFieldLabel(field)),
configSetupFieldType(field.Type),
field.Required,
strconv.Quote(field.Default),
strconv.Quote(name),
)
}
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
import (
"flag"
"strings"
2026-05-05 10:23:14 +00:00
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
2026-05-02 10:02:23 +00:00
)
type ConfigFlags struct {
values map[string]*string
}
func AddConfigFlags(fs *flag.FlagSet) ConfigFlags {
if fs == nil {
fs = flag.CommandLine
}
flags := ConfigFlags{
values: make(map[string]*string),
}
%s
return flags
}
func ConfigFlagValues(flags ConfigFlags) map[string]string {
values := make(map[string]string)
for name, value := range flags.values {
if value == nil {
continue
}
if trimmed := strings.TrimSpace(*value); trimmed != "" {
values[name] = trimmed
}
}
return values
}
func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {
return []fwcli.FieldSpec{
%s
}
}
func SetupFields(existing map[string]string) []fwcli.SetupField {
if existing == nil {
existing = map[string]string{}
}
return []fwcli.SetupField{
%s
}
}
func replaceProfile(value, profile string) string {
return strings.ReplaceAll(value, "{profile}", strings.TrimSpace(profile))
}
`, packageName, flagsBuilder.String(), specsBuilder.String(), setupBuilder.String())
return formatGenerated("config", source)
}
func configFieldLabel(field manifest.ConfigField) string {
if label := strings.TrimSpace(field.Label); label != "" {
return label
}
return strings.TrimSpace(field.Name)
}
func configSourceList(sources []string) string {
if len(sources) == 0 {
return ""
}
parts := make([]string, 0, len(sources))
for _, source := range sources {
switch strings.TrimSpace(source) {
case "flag":
parts = append(parts, "fwcli.SourceFlag")
case "env":
parts = append(parts, "fwcli.SourceEnv")
case "config":
parts = append(parts, "fwcli.SourceConfig")
case "secret":
parts = append(parts, "fwcli.SourceSecret")
}
}
return strings.Join(parts, ", ")
}
func configSetupFieldType(fieldType string) string {
switch strings.TrimSpace(fieldType) {
case "url":
return "fwcli.SetupFieldURL"
case "secret":
return "fwcli.SetupFieldSecret"
case "bool":
return "fwcli.SetupFieldBool"
case "list":
return "fwcli.SetupFieldList"
default:
return "fwcli.SetupFieldString"
}
}
2026-05-02 09:57:44 +00:00
func formatGenerated(name, source string) (string, error) {
formatted, err := format.Source([]byte(source))
if err != nil {
return "", fmt.Errorf("format generated %s: %w", name, err)
}
return string(formatted), nil
}
2026-05-02 09:46:47 +00:00
func writeGeneratedFile(path, content string, mode os.FileMode) error {
current, err := os.ReadFile(path)
if err == nil && bytes.Equal(current, []byte(content)) {
return nil
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read generated file %q: %w", path, err)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create generated directory %q: %w", dir, err)
}
if mode == 0 {
mode = 0o644
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
return fmt.Errorf("write generated file %q: %w", path, err)
}
return nil
}