diff --git a/Makefile b/Makefile index f6f83ff..971724e 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ endif OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(EXT) -.PHONY: build test +.PHONY: build test generate generate-check build: @mkdir -p $(BUILD_DIR) $(GOCACHE) @@ -23,3 +23,11 @@ build: test: @mkdir -p $(GOCACHE) GOCACHE=$(GOCACHE) go test ./... + +generate: + @mkdir -p $(GOCACHE) + GOCACHE=$(GOCACHE) go run forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework generate + +generate-check: + @mkdir -p $(GOCACHE) + GOCACHE=$(GOCACHE) go run forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework generate --check diff --git a/README.md b/README.md index a189298..79c5f8e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Le binaire s’appuie maintenant sur [`mcp-framework`](../mcp-framework) pour : - le stockage JSON de configuration dans `os.UserConfigDir()` - le stockage du mot de passe dans le wallet natif de l’OS - le manifeste `mcp.toml` +- les helpers Go générés depuis `mcp.toml` (`mcpgen/`) - l’auto-update via `email-mcp update` ## Commandes @@ -44,7 +45,7 @@ Le profil actif est résolu dans cet ordre : 4. `[profiles].default` dans `mcp.toml` 5. `default` -Les credentials IMAP sont résolus ensuite via le résolveur multi-sources du framework (RC3) : +Les credentials IMAP sont résolus ensuite via les champs `[[config.fields]]` du manifeste et les helpers générés par le framework : 1. `host` : `EMAIL_MCP_HOST` puis `config.json` 2. `username` : `EMAIL_MCP_USERNAME` puis `config.json` @@ -221,3 +222,10 @@ Pour lancer les tests : ```sh make test ``` + +Pour régénérer la glue framework après une modification de `mcp.toml` : + +```sh +make generate +make generate-check +``` diff --git a/go.mod b/go.mod index b7d437a..718ca14 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module email-mcp go 1.25.0 require ( - gitea.lclr.dev/AI/mcp-framework v1.4.2 + forge.lclr.dev/AI/mcp-framework v1.9.0 github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-message v0.18.2 github.com/godbus/dbus/v5 v5.2.2 diff --git a/go.sum b/go.sum index 9938917..4b0825a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -gitea.lclr.dev/AI/mcp-framework v1.4.2 h1:VRJsnQWnxD0Kzdm/XiasYefe4gYHCt376i44WkkQsas= -gitea.lclr.dev/AI/mcp-framework v1.4.2/go.mod h1:kUVMrL3/UBYgjOsW7sJCs3V0pO0qoJJMpIpueoTsoA4= +forge.lclr.dev/AI/mcp-framework v1.9.0 h1:8i2CHQlQo/mRG1BE2UArHptAa/HC7AOhZBIqz8md8Vk= +forge.lclr.dev/AI/mcp-framework v1.9.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= diff --git a/internal/cli/app.go b/internal/cli/app.go index be3cf51..7ebb2b6 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -11,24 +11,24 @@ import ( "sort" "strings" - frameworkbootstrap "gitea.lclr.dev/AI/mcp-framework/bootstrap" - frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli" - frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" - frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" - frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" - frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update" + "email-mcp/mcpgen" + frameworkbootstrap "forge.lclr.dev/AI/mcp-framework/bootstrap" + frameworkcli "forge.lclr.dev/AI/mcp-framework/cli" + frameworkconfig "forge.lclr.dev/AI/mcp-framework/config" + frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest" + frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore" + frameworkupdate "forge.lclr.dev/AI/mcp-framework/update" "email-mcp/internal/mcpserver" "email-mcp/internal/secretstore" ) const ( - binaryName = "email-mcp" + binaryName = mcpgen.BinaryName defaultProfileEnv = "EMAIL_MCP_PROFILE" hostEnv = "EMAIL_MCP_HOST" usernameEnv = "EMAIL_MCP_USERNAME" passwordEnv = "EMAIL_MCP_PASSWORD" - binaryDescription = "Local MCP server to read an IMAP mailbox." fallbackProfile = "default" ) @@ -125,14 +125,14 @@ func (a *App) runBootstrap(ctx context.Context, args []string) error { metadata := a.runtimeMetadata() return frameworkbootstrap.Run(ctx, frameworkbootstrap.Options{ - BinaryName: metadata.BinaryName, - Description: metadata.Description, - Version: a.version, + BinaryName: metadata.BinaryName, + Description: metadata.Description, + Version: a.version, EnableDoctorAlias: true, - Args: args, - Stdin: a.stdin, - Stdout: a.stdout, - Stderr: a.stderr, + Args: args, + Stdin: a.stdin, + Stdout: a.stdout, + Stderr: a.stderr, Hooks: frameworkbootstrap.Hooks{ Setup: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { return a.runConfig(ctx, frameworkbootstrap.CommandSetup, inv.Args) @@ -264,7 +264,7 @@ func (a *App) runConfigShow(ctx context.Context, args []string) error { return mapAppError(err) } - resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName)) + resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName)) if err != nil { var missingErr *frameworkcli.MissingRequiredValuesError if !errors.As(err, &missingErr) { @@ -393,18 +393,13 @@ func (a *App) runUpdate(ctx context.Context, args []string) error { return fmt.Errorf("resolve executable path: %w", err) } - manifestFile, err := a.loadManifestForExecutable(executablePath) + options, err := mcpgen.UpdateOptionsFrom(filepath.Dir(executablePath), a.version, a.stdout) if err != nil { return err } + options.ExecutablePath = executablePath - return frameworkupdate.Run(ctx, frameworkupdate.Options{ - CurrentVersion: a.version, - ExecutablePath: executablePath, - BinaryName: a.runtimeMetadata().BinaryName, - ReleaseSource: manifestFile.Update.ReleaseSource(), - Stdout: a.stdout, - }) + return frameworkupdate.Run(ctx, options) } func (a *App) loadManifestForExecutable(executablePath string) (frameworkmanifest.File, error) { @@ -445,7 +440,7 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error) return secretstore.Credential{}, err } - resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName)) + resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName)) if err != nil { var missingErr *frameworkcli.MissingRequiredValuesError if errors.As(err, &missingErr) { @@ -470,68 +465,24 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error) return cred, nil } -func credentialFieldSpecs(profileName string) []frameworkcli.FieldSpec { - return []frameworkcli.FieldSpec{ - { - Name: "host", - Required: true, - Sources: []frameworkcli.ValueSource{ - frameworkcli.SourceEnv, - frameworkcli.SourceConfig, - }, - EnvKey: hostEnv, - ConfigKey: "host", - }, - { - Name: "username", - Required: true, - Sources: []frameworkcli.ValueSource{ - frameworkcli.SourceEnv, - frameworkcli.SourceConfig, - }, - EnvKey: usernameEnv, - ConfigKey: "username", - }, - passwordFieldSpec(profileName), +func profileFieldSpecs(profileName string) []frameworkcli.FieldSpec { + specs := mcpgen.ResolveFieldSpecs(profileName) + profileSpecs := make([]frameworkcli.FieldSpec, 0, len(specs)) + for _, spec := range specs { + if spec.Name == "host" || spec.Name == "username" { + profileSpecs = append(profileSpecs, spec) + } } + return profileSpecs } -func profileFieldSpecs() []frameworkcli.FieldSpec { - return []frameworkcli.FieldSpec{ - { - Name: "host", - Required: true, - Sources: []frameworkcli.ValueSource{ - frameworkcli.SourceEnv, - frameworkcli.SourceConfig, - }, - EnvKey: hostEnv, - ConfigKey: "host", - }, - { - Name: "username", - Required: true, - Sources: []frameworkcli.ValueSource{ - frameworkcli.SourceEnv, - frameworkcli.SourceConfig, - }, - EnvKey: usernameEnv, - ConfigKey: "username", - }, - } -} - -func passwordFieldSpec(profileName string) frameworkcli.FieldSpec { - return frameworkcli.FieldSpec{ - Name: "password", - Required: true, - Sources: []frameworkcli.ValueSource{ - frameworkcli.SourceEnv, - frameworkcli.SourceSecret, - }, - EnvKey: passwordEnv, - SecretKey: passwordSecretName(profileName), +func passwordOnlyFieldSpecs(profileName string) []frameworkcli.FieldSpec { + for _, spec := range mcpgen.ResolveFieldSpecs(profileName) { + if spec.Name == "password" { + return []frameworkcli.FieldSpec{spec} + } } + return nil } func resolveCredentialFields(profile ProfileConfig, store secretStore, fields []frameworkcli.FieldSpec) (frameworkcli.Resolution, error) { @@ -586,6 +537,11 @@ func loadStoredPassword(store secretStore, profileName string) (string, bool, er } func passwordSecretName(profileName string) string { + for _, spec := range mcpgen.ResolveFieldSpecs(profileName) { + if spec.Name == "password" && strings.TrimSpace(spec.SecretKey) != "" { + return spec.SecretKey + } + } return "imap-password/" + strings.TrimSpace(profileName) } @@ -633,8 +589,8 @@ type runtimeMetadata struct { func (a *App) runtimeMetadata() runtimeMetadata { metadata := runtimeMetadata{ - BinaryName: binaryName, - Description: binaryDescription, + BinaryName: mcpgen.BinaryName, + Description: mcpgen.DefaultDescription, DefaultProfile: fallbackProfile, } diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 4abfc53..d90297b 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -11,9 +11,9 @@ import ( "strings" "testing" - frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" - frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" - frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" + frameworkconfig "forge.lclr.dev/AI/mcp-framework/config" + frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest" + frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore" "email-mcp/internal/imapclient" "email-mcp/internal/mcpserver" diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 9e6f729..e3fef54 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -10,10 +10,11 @@ import ( "strings" "time" - frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli" - frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" - frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" - frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update" + "email-mcp/mcpgen" + frameworkcli "forge.lclr.dev/AI/mcp-framework/cli" + frameworkconfig "forge.lclr.dev/AI/mcp-framework/config" + frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest" + frameworkupdate "forge.lclr.dev/AI/mcp-framework/update" ) func (a *App) runDoctor(ctx context.Context, args []string) error { @@ -33,7 +34,7 @@ func (a *App) runDoctor(ctx context.Context, args []string) error { metadata := a.runtimeMetadata() report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{ - ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](binaryName)), + ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)), SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.openSecretStore), ManifestDir: a.doctorManifestDir(), ManifestValidator: func(file frameworkmanifest.File, _ string) []string { @@ -73,7 +74,7 @@ func (a *App) doctorRequiredProfileFieldsCheck(profileFlag string) frameworkcli. ) check := frameworkcli.RequiredResolvedFieldsCheck(frameworkcli.ResolveOptions{ - Fields: profileFieldSpecs(), + Fields: profileFieldSpecs(a.resolveDoctorProfileName(profileFlag)), Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{ Env: frameworkcli.EnvLookup(os.LookupEnv), Config: func(key string) (string, bool, error) { @@ -123,7 +124,7 @@ func (a *App) doctorPasswordCheck(profileFlag string) frameworkcli.DoctorCheck { resolution, err := resolveCredentialFields( ProfileConfig{}, store, - []frameworkcli.FieldSpec{passwordFieldSpec(profileName)}, + passwordOnlyFieldSpecs(profileName), ) if err != nil { var missingErr *frameworkcli.MissingRequiredValuesError diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 45bba6f..26b3699 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -8,7 +8,8 @@ import ( "os" "strings" - frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli" + "email-mcp/mcpgen" + frameworkcli "forge.lclr.dev/AI/mcp-framework/cli" "email-mcp/internal/secretstore" ) @@ -76,32 +77,20 @@ func (p *InteractiveConfigPrompter) promptCredentialWithSetupEngine(existing sec password = existing.Password } + fields := mcpgen.SetupFields(map[string]string{"password": password}) + for i := range fields { + switch fields[i].Name { + case "host": + fields[i].Default = existing.Host + case "username": + fields[i].Default = existing.Username + } + } + result, err := frameworkcli.RunSetup(frameworkcli.SetupOptions{ Stdin: p.stdinFile, Stdout: p.output, - Fields: []frameworkcli.SetupField{ - { - Name: "host", - Label: "IMAP host", - Type: frameworkcli.SetupFieldString, - Required: true, - Default: existing.Host, - }, - { - Name: "username", - Label: "Username", - Type: frameworkcli.SetupFieldString, - Required: true, - Default: existing.Username, - }, - { - Name: "password", - Label: "Password", - Type: frameworkcli.SetupFieldSecret, - Required: true, - ExistingSecret: password, - }, - }, + Fields: fields, }) if err != nil { return secretstore.Credential{}, err diff --git a/internal/cli/wire.go b/internal/cli/wire.go index 1255ea7..3001011 100644 --- a/internal/cli/wire.go +++ b/internal/cli/wire.go @@ -6,9 +6,9 @@ import ( "os" "strings" - frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" - frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" - frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" + "email-mcp/mcpgen" + frameworkconfig "forge.lclr.dev/AI/mcp-framework/config" + frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore" "email-mcp/internal/imapclient" "email-mcp/internal/mcpserver" @@ -48,6 +48,7 @@ func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, version strin } func (f runtimeFactories) withDefaults() runtimeFactories { + useGeneratedManifest := f.loadManifest == nil if f.newPrompter == nil { f.newPrompter = func(input io.Reader, output io.Writer) ConfigPrompter { return NewInteractiveConfigPrompter(input, output) @@ -55,28 +56,29 @@ func (f runtimeFactories) withDefaults() runtimeFactories { } if f.newConfigStore == nil { f.newConfigStore = func() profileConfigStore { - return frameworkconfig.NewStore[ProfileConfig]("email-mcp") + return frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName) } } if f.loadManifest == nil { - f.loadManifest = frameworkmanifest.LoadDefault + f.loadManifest = mcpgen.LoadManifest } if f.resolveExecutable == nil { f.resolveExecutable = os.Executable } if f.openSecretStore == nil { f.openSecretStore = func() (secretStore, error) { + if useGeneratedManifest { + return mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{ + ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable), + LookupEnv: profilePasswordLookupEnv, + }) + } + return frameworksecretstore.OpenFromManifest(frameworksecretstore.OpenFromManifestOptions{ - ServiceName: "email-mcp", + ServiceName: mcpgen.BinaryName, ManifestLoader: frameworksecretstore.ManifestLoader(f.loadManifest), ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable), - LookupEnv: func(name string) (string, bool) { - trimmedName := strings.TrimSpace(name) - if strings.HasPrefix(trimmedName, "imap-password/") { - return os.LookupEnv(passwordEnv) - } - return os.LookupEnv(trimmedName) - }, + LookupEnv: profilePasswordLookupEnv, }) } } @@ -94,6 +96,14 @@ func (f runtimeFactories) withDefaults() runtimeFactories { return f } +func profilePasswordLookupEnv(name string) (string, bool) { + trimmedName := strings.TrimSpace(name) + if strings.HasPrefix(trimmedName, "imap-password/") { + return os.LookupEnv(passwordEnv) + } + return os.LookupEnv(trimmedName) +} + type staticCredentialStore struct { credential secretstore.Credential } diff --git a/internal/cli/wire_test.go b/internal/cli/wire_test.go index 75e7144..2974abe 100644 --- a/internal/cli/wire_test.go +++ b/internal/cli/wire_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" + frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest" ) func TestBuildAppReturnsConfiguredApp(t *testing.T) { diff --git a/mcp.toml b/mcp.toml index 3f24c8c..c40c491 100644 --- a/mcp.toml +++ b/mcp.toml @@ -23,3 +23,30 @@ known = ["default"] [bootstrap] description = "Local MCP server to read an IMAP mailbox." + +[[config.fields]] +name = "host" +env = "EMAIL_MCP_HOST" +config_key = "host" +type = "string" +label = "IMAP host" +required = true +sources = ["env", "config"] + +[[config.fields]] +name = "username" +env = "EMAIL_MCP_USERNAME" +config_key = "username" +type = "string" +label = "Username" +required = true +sources = ["env", "config"] + +[[config.fields]] +name = "password" +env = "EMAIL_MCP_PASSWORD" +secret_key_template = "imap-password/{profile}" +type = "secret" +label = "Password" +required = true +sources = ["env", "secret"] diff --git a/mcpgen/config.go b/mcpgen/config.go new file mode 100644 index 0000000..8fe4a9f --- /dev/null +++ b/mcpgen/config.go @@ -0,0 +1,63 @@ +// Code generated by mcp-framework generate. DO NOT EDIT. + +package mcpgen + +import ( + "flag" + "strings" + + fwcli "forge.lclr.dev/AI/mcp-framework/cli" +) + +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), + } + + 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{ + {Name: "host", Required: true, DefaultValue: "", Sources: []fwcli.ValueSource{fwcli.SourceEnv, fwcli.SourceConfig}, FlagKey: "", EnvKey: "EMAIL_MCP_HOST", ConfigKey: "host", SecretKey: replaceProfile("", profile)}, + {Name: "username", Required: true, DefaultValue: "", Sources: []fwcli.ValueSource{fwcli.SourceEnv, fwcli.SourceConfig}, FlagKey: "", EnvKey: "EMAIL_MCP_USERNAME", ConfigKey: "username", SecretKey: replaceProfile("", profile)}, + {Name: "password", Required: true, DefaultValue: "", Sources: []fwcli.ValueSource{fwcli.SourceEnv, fwcli.SourceSecret}, FlagKey: "", EnvKey: "EMAIL_MCP_PASSWORD", ConfigKey: "", SecretKey: replaceProfile("imap-password/{profile}", profile)}, + } +} + +func SetupFields(existing map[string]string) []fwcli.SetupField { + if existing == nil { + existing = map[string]string{} + } + + return []fwcli.SetupField{ + {Name: "host", Label: "IMAP host", Type: fwcli.SetupFieldString, Required: true, Default: "", ExistingSecret: existing["host"]}, + {Name: "username", Label: "Username", Type: fwcli.SetupFieldString, Required: true, Default: "", ExistingSecret: existing["username"]}, + {Name: "password", Label: "Password", Type: fwcli.SetupFieldSecret, Required: true, Default: "", ExistingSecret: existing["password"]}, + } +} + +func replaceProfile(value, profile string) string { + return strings.ReplaceAll(value, "{profile}", strings.TrimSpace(profile)) +} diff --git a/mcpgen/generated_test.go b/mcpgen/generated_test.go new file mode 100644 index 0000000..25d38b4 --- /dev/null +++ b/mcpgen/generated_test.go @@ -0,0 +1,68 @@ +package mcpgen + +import ( + "os" + "testing" + + fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest" +) + +func TestGeneratedManifestFallsBackToEmbeddedRootManifest(t *testing.T) { + previousDir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd returned error: %v", err) + } + if err := os.Chdir(t.TempDir()); err != nil { + t.Fatalf("Chdir temp dir returned error: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(previousDir); err != nil { + t.Fatalf("restore working directory: %v", err) + } + }) + + manifestFile, source, err := LoadManifest(".") + if err != nil { + t.Fatalf("LoadManifest returned error: %v", err) + } + + if source != fwmanifest.EmbeddedSource { + t.Fatalf("source = %q, want %q", source, fwmanifest.EmbeddedSource) + } + if manifestFile.BinaryName != "email-mcp" { + t.Fatalf("BinaryName = %q, want email-mcp", manifestFile.BinaryName) + } + if len(manifestFile.Config.Fields) != 3 { + t.Fatalf("config fields = %d, want 3", len(manifestFile.Config.Fields)) + } +} + +func TestGeneratedConfigHelpersExposeIMAPResolutionFields(t *testing.T) { + specs := ResolveFieldSpecs("work") + if len(specs) != 3 { + t.Fatalf("ResolveFieldSpecs returned %d fields, want 3", len(specs)) + } + + if specs[0].Name != "host" || specs[0].EnvKey != "EMAIL_MCP_HOST" || specs[0].ConfigKey != "host" { + t.Fatalf("host spec = %+v", specs[0]) + } + if specs[1].Name != "username" || specs[1].EnvKey != "EMAIL_MCP_USERNAME" || specs[1].ConfigKey != "username" { + t.Fatalf("username spec = %+v", specs[1]) + } + if specs[2].Name != "password" || specs[2].EnvKey != "EMAIL_MCP_PASSWORD" || specs[2].SecretKey != "imap-password/work" { + t.Fatalf("password spec = %+v", specs[2]) + } +} + +func TestGeneratedManifestPrefersRootFileWhenPresent(t *testing.T) { + manifestFile, source, err := LoadManifest(".") + if err != nil { + t.Fatalf("LoadManifest returned error: %v", err) + } + if source == fwmanifest.EmbeddedSource { + t.Fatalf("source = %q, want root manifest path", source) + } + if manifestFile.BinaryName != "email-mcp" { + t.Fatalf("BinaryName = %q, want email-mcp", manifestFile.BinaryName) + } +} diff --git a/mcpgen/manifest.go b/mcpgen/manifest.go new file mode 100644 index 0000000..bb41a34 --- /dev/null +++ b/mcpgen/manifest.go @@ -0,0 +1,11 @@ +// Code generated by mcp-framework generate. DO NOT EDIT. + +package mcpgen + +import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest" + +const embeddedManifest = "binary_name = \"email-mcp\"\ndocs_url = \"https://gitea.lclr.dev/AI/email-mcp\"\n\n[update]\nsource_name = \"email-mcp releases\"\ndriver = \"gitea\"\nrepository = \"AI/email-mcp\"\nbase_url = \"https://gitea.lclr.dev\"\nasset_name_template = \"{binary}-{os}-{arch}{ext}\"\nchecksum_asset_name = \"{asset}.sha256\"\nchecksum_required = true\ntoken_env_names = [\"GITEA_TOKEN\"]\n\n[environment]\nknown = [\"EMAIL_MCP_PROFILE\", \"EMAIL_MCP_HOST\", \"EMAIL_MCP_USERNAME\", \"EMAIL_MCP_PASSWORD\"]\n\n[secret_store]\nbackend_policy = \"auto\"\n\n[profiles]\ndefault = \"default\"\nknown = [\"default\"]\n\n[bootstrap]\ndescription = \"Local MCP server to read an IMAP mailbox.\"\n\n[[config.fields]]\nname = \"host\"\nenv = \"EMAIL_MCP_HOST\"\nconfig_key = \"host\"\ntype = \"string\"\nlabel = \"IMAP host\"\nrequired = true\nsources = [\"env\", \"config\"]\n\n[[config.fields]]\nname = \"username\"\nenv = \"EMAIL_MCP_USERNAME\"\nconfig_key = \"username\"\ntype = \"string\"\nlabel = \"Username\"\nrequired = true\nsources = [\"env\", \"config\"]\n\n[[config.fields]]\nname = \"password\"\nenv = \"EMAIL_MCP_PASSWORD\"\nsecret_key_template = \"imap-password/{profile}\"\ntype = \"secret\"\nlabel = \"Password\"\nrequired = true\nsources = [\"env\", \"secret\"]\n" + +func LoadManifest(startDir string) (fwmanifest.File, string, error) { + return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest) +} diff --git a/mcpgen/metadata.go b/mcpgen/metadata.go new file mode 100644 index 0000000..7b1ba84 --- /dev/null +++ b/mcpgen/metadata.go @@ -0,0 +1,27 @@ +// Code generated by mcp-framework generate. DO NOT EDIT. + +package mcpgen + +import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest" + +const BinaryName = "email-mcp" +const DefaultDescription = "Local MCP server to read an IMAP mailbox." +const DocsURL = "https://gitea.lclr.dev/AI/email-mcp" + +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 +} diff --git a/mcpgen/secretstore.go b/mcpgen/secretstore.go new file mode 100644 index 0000000..e6d822c --- /dev/null +++ b/mcpgen/secretstore.go @@ -0,0 +1,91 @@ +// Code generated by mcp-framework generate. DO NOT EDIT. + +package mcpgen + +import ( + "os" + "path/filepath" + "strings" + + fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore" +) + +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 +} + +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, + 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, + 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 +} diff --git a/mcpgen/update.go b/mcpgen/update.go new file mode 100644 index 0000000..09cafff --- /dev/null +++ b/mcpgen/update.go @@ -0,0 +1,59 @@ +// Code generated by mcp-framework generate. DO NOT EDIT. + +package mcpgen + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + + fwupdate "forge.lclr.dev/AI/mcp-framework/update" +) + +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) +}