feat: upgrade mcp framework generation

This commit is contained in:
thibaud-lclr 2026-05-11 11:16:37 +02:00
parent be33b467a6
commit be2b7e631b
17 changed files with 456 additions and 138 deletions

View file

@ -14,7 +14,7 @@ endif
OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(EXT) OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(EXT)
.PHONY: build test .PHONY: build test generate generate-check
build: build:
@mkdir -p $(BUILD_DIR) $(GOCACHE) @mkdir -p $(BUILD_DIR) $(GOCACHE)
@ -23,3 +23,11 @@ build:
test: test:
@mkdir -p $(GOCACHE) @mkdir -p $(GOCACHE)
GOCACHE=$(GOCACHE) go test ./... 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

View file

@ -8,6 +8,7 @@ Le binaire sappuie maintenant sur [`mcp-framework`](../mcp-framework) pour :
- le stockage JSON de configuration dans `os.UserConfigDir()` - le stockage JSON de configuration dans `os.UserConfigDir()`
- le stockage du mot de passe dans le wallet natif de lOS - le stockage du mot de passe dans le wallet natif de lOS
- le manifeste `mcp.toml` - le manifeste `mcp.toml`
- les helpers Go générés depuis `mcp.toml` (`mcpgen/`)
- lauto-update via `email-mcp update` - lauto-update via `email-mcp update`
## Commandes ## Commandes
@ -44,7 +45,7 @@ Le profil actif est résolu dans cet ordre :
4. `[profiles].default` dans `mcp.toml` 4. `[profiles].default` dans `mcp.toml`
5. `default` 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` 1. `host` : `EMAIL_MCP_HOST` puis `config.json`
2. `username` : `EMAIL_MCP_USERNAME` puis `config.json` 2. `username` : `EMAIL_MCP_USERNAME` puis `config.json`
@ -221,3 +222,10 @@ Pour lancer les tests :
```sh ```sh
make test make test
``` ```
Pour régénérer la glue framework après une modification de `mcp.toml` :
```sh
make generate
make generate-check
```

2
go.mod
View file

@ -3,7 +3,7 @@ module email-mcp
go 1.25.0 go 1.25.0
require ( 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-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2 github.com/emersion/go-message v0.18.2
github.com/godbus/dbus/v5 v5.2.2 github.com/godbus/dbus/v5 v5.2.2

4
go.sum
View file

@ -1,5 +1,5 @@
gitea.lclr.dev/AI/mcp-framework v1.4.2 h1:VRJsnQWnxD0Kzdm/XiasYefe4gYHCt376i44WkkQsas= forge.lclr.dev/AI/mcp-framework v1.9.0 h1:8i2CHQlQo/mRG1BE2UArHptAa/HC7AOhZBIqz8md8Vk=
gitea.lclr.dev/AI/mcp-framework v1.4.2/go.mod h1:kUVMrL3/UBYgjOsW7sJCs3V0pO0qoJJMpIpueoTsoA4= 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 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= 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= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=

View file

@ -11,24 +11,24 @@ import (
"sort" "sort"
"strings" "strings"
frameworkbootstrap "gitea.lclr.dev/AI/mcp-framework/bootstrap" "email-mcp/mcpgen"
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli" frameworkbootstrap "forge.lclr.dev/AI/mcp-framework/bootstrap"
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update" frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
frameworkupdate "forge.lclr.dev/AI/mcp-framework/update"
"email-mcp/internal/mcpserver" "email-mcp/internal/mcpserver"
"email-mcp/internal/secretstore" "email-mcp/internal/secretstore"
) )
const ( const (
binaryName = "email-mcp" binaryName = mcpgen.BinaryName
defaultProfileEnv = "EMAIL_MCP_PROFILE" defaultProfileEnv = "EMAIL_MCP_PROFILE"
hostEnv = "EMAIL_MCP_HOST" hostEnv = "EMAIL_MCP_HOST"
usernameEnv = "EMAIL_MCP_USERNAME" usernameEnv = "EMAIL_MCP_USERNAME"
passwordEnv = "EMAIL_MCP_PASSWORD" passwordEnv = "EMAIL_MCP_PASSWORD"
binaryDescription = "Local MCP server to read an IMAP mailbox."
fallbackProfile = "default" fallbackProfile = "default"
) )
@ -264,7 +264,7 @@ func (a *App) runConfigShow(ctx context.Context, args []string) error {
return mapAppError(err) return mapAppError(err)
} }
resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName)) resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName))
if err != nil { if err != nil {
var missingErr *frameworkcli.MissingRequiredValuesError var missingErr *frameworkcli.MissingRequiredValuesError
if !errors.As(err, &missingErr) { 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) 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 { if err != nil {
return err return err
} }
options.ExecutablePath = executablePath
return frameworkupdate.Run(ctx, frameworkupdate.Options{ return frameworkupdate.Run(ctx, options)
CurrentVersion: a.version,
ExecutablePath: executablePath,
BinaryName: a.runtimeMetadata().BinaryName,
ReleaseSource: manifestFile.Update.ReleaseSource(),
Stdout: a.stdout,
})
} }
func (a *App) loadManifestForExecutable(executablePath string) (frameworkmanifest.File, error) { 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 return secretstore.Credential{}, err
} }
resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName)) resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName))
if err != nil { if err != nil {
var missingErr *frameworkcli.MissingRequiredValuesError var missingErr *frameworkcli.MissingRequiredValuesError
if errors.As(err, &missingErr) { if errors.As(err, &missingErr) {
@ -470,68 +465,24 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error)
return cred, nil return cred, nil
} }
func credentialFieldSpecs(profileName string) []frameworkcli.FieldSpec { func profileFieldSpecs(profileName string) []frameworkcli.FieldSpec {
return []frameworkcli.FieldSpec{ specs := mcpgen.ResolveFieldSpecs(profileName)
{ profileSpecs := make([]frameworkcli.FieldSpec, 0, len(specs))
Name: "host", for _, spec := range specs {
Required: true, if spec.Name == "host" || spec.Name == "username" {
Sources: []frameworkcli.ValueSource{ profileSpecs = append(profileSpecs, spec)
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),
} }
}
return profileSpecs
} }
func profileFieldSpecs() []frameworkcli.FieldSpec { func passwordOnlyFieldSpecs(profileName string) []frameworkcli.FieldSpec {
return []frameworkcli.FieldSpec{ for _, spec := range mcpgen.ResolveFieldSpecs(profileName) {
{ if spec.Name == "password" {
Name: "host", return []frameworkcli.FieldSpec{spec}
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),
} }
return nil
} }
func resolveCredentialFields(profile ProfileConfig, store secretStore, fields []frameworkcli.FieldSpec) (frameworkcli.Resolution, error) { 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 { 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) return "imap-password/" + strings.TrimSpace(profileName)
} }
@ -633,8 +589,8 @@ type runtimeMetadata struct {
func (a *App) runtimeMetadata() runtimeMetadata { func (a *App) runtimeMetadata() runtimeMetadata {
metadata := runtimeMetadata{ metadata := runtimeMetadata{
BinaryName: binaryName, BinaryName: mcpgen.BinaryName,
Description: binaryDescription, Description: mcpgen.DefaultDescription,
DefaultProfile: fallbackProfile, DefaultProfile: fallbackProfile,
} }

View file

@ -11,9 +11,9 @@ import (
"strings" "strings"
"testing" "testing"
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
"email-mcp/internal/imapclient" "email-mcp/internal/imapclient"
"email-mcp/internal/mcpserver" "email-mcp/internal/mcpserver"

View file

@ -10,10 +10,11 @@ import (
"strings" "strings"
"time" "time"
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli" "email-mcp/mcpgen"
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update" 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 { 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() metadata := a.runtimeMetadata()
report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{ 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), SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.openSecretStore),
ManifestDir: a.doctorManifestDir(), ManifestDir: a.doctorManifestDir(),
ManifestValidator: func(file frameworkmanifest.File, _ string) []string { ManifestValidator: func(file frameworkmanifest.File, _ string) []string {
@ -73,7 +74,7 @@ func (a *App) doctorRequiredProfileFieldsCheck(profileFlag string) frameworkcli.
) )
check := frameworkcli.RequiredResolvedFieldsCheck(frameworkcli.ResolveOptions{ check := frameworkcli.RequiredResolvedFieldsCheck(frameworkcli.ResolveOptions{
Fields: profileFieldSpecs(), Fields: profileFieldSpecs(a.resolveDoctorProfileName(profileFlag)),
Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{ Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{
Env: frameworkcli.EnvLookup(os.LookupEnv), Env: frameworkcli.EnvLookup(os.LookupEnv),
Config: func(key string) (string, bool, error) { Config: func(key string) (string, bool, error) {
@ -123,7 +124,7 @@ func (a *App) doctorPasswordCheck(profileFlag string) frameworkcli.DoctorCheck {
resolution, err := resolveCredentialFields( resolution, err := resolveCredentialFields(
ProfileConfig{}, ProfileConfig{},
store, store,
[]frameworkcli.FieldSpec{passwordFieldSpec(profileName)}, passwordOnlyFieldSpecs(profileName),
) )
if err != nil { if err != nil {
var missingErr *frameworkcli.MissingRequiredValuesError var missingErr *frameworkcli.MissingRequiredValuesError

View file

@ -8,7 +8,8 @@ import (
"os" "os"
"strings" "strings"
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli" "email-mcp/mcpgen"
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
"email-mcp/internal/secretstore" "email-mcp/internal/secretstore"
) )
@ -76,32 +77,20 @@ func (p *InteractiveConfigPrompter) promptCredentialWithSetupEngine(existing sec
password = existing.Password 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{ result, err := frameworkcli.RunSetup(frameworkcli.SetupOptions{
Stdin: p.stdinFile, Stdin: p.stdinFile,
Stdout: p.output, Stdout: p.output,
Fields: []frameworkcli.SetupField{ Fields: fields,
{
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,
},
},
}) })
if err != nil { if err != nil {
return secretstore.Credential{}, err return secretstore.Credential{}, err

View file

@ -6,9 +6,9 @@ import (
"os" "os"
"strings" "strings"
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" "email-mcp/mcpgen"
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
"email-mcp/internal/imapclient" "email-mcp/internal/imapclient"
"email-mcp/internal/mcpserver" "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 { func (f runtimeFactories) withDefaults() runtimeFactories {
useGeneratedManifest := f.loadManifest == nil
if f.newPrompter == nil { if f.newPrompter == nil {
f.newPrompter = func(input io.Reader, output io.Writer) ConfigPrompter { f.newPrompter = func(input io.Reader, output io.Writer) ConfigPrompter {
return NewInteractiveConfigPrompter(input, output) return NewInteractiveConfigPrompter(input, output)
@ -55,28 +56,29 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
} }
if f.newConfigStore == nil { if f.newConfigStore == nil {
f.newConfigStore = func() profileConfigStore { f.newConfigStore = func() profileConfigStore {
return frameworkconfig.NewStore[ProfileConfig]("email-mcp") return frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)
} }
} }
if f.loadManifest == nil { if f.loadManifest == nil {
f.loadManifest = frameworkmanifest.LoadDefault f.loadManifest = mcpgen.LoadManifest
} }
if f.resolveExecutable == nil { if f.resolveExecutable == nil {
f.resolveExecutable = os.Executable f.resolveExecutable = os.Executable
} }
if f.openSecretStore == nil { if f.openSecretStore == nil {
f.openSecretStore = func() (secretStore, error) { f.openSecretStore = func() (secretStore, error) {
if useGeneratedManifest {
return mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{
ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable),
LookupEnv: profilePasswordLookupEnv,
})
}
return frameworksecretstore.OpenFromManifest(frameworksecretstore.OpenFromManifestOptions{ return frameworksecretstore.OpenFromManifest(frameworksecretstore.OpenFromManifestOptions{
ServiceName: "email-mcp", ServiceName: mcpgen.BinaryName,
ManifestLoader: frameworksecretstore.ManifestLoader(f.loadManifest), ManifestLoader: frameworksecretstore.ManifestLoader(f.loadManifest),
ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable), ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable),
LookupEnv: func(name string) (string, bool) { LookupEnv: profilePasswordLookupEnv,
trimmedName := strings.TrimSpace(name)
if strings.HasPrefix(trimmedName, "imap-password/") {
return os.LookupEnv(passwordEnv)
}
return os.LookupEnv(trimmedName)
},
}) })
} }
} }
@ -94,6 +96,14 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
return f 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 { type staticCredentialStore struct {
credential secretstore.Credential credential secretstore.Credential
} }

View file

@ -4,7 +4,7 @@ import (
"strings" "strings"
"testing" "testing"
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
) )
func TestBuildAppReturnsConfiguredApp(t *testing.T) { func TestBuildAppReturnsConfiguredApp(t *testing.T) {

View file

@ -23,3 +23,30 @@ known = ["default"]
[bootstrap] [bootstrap]
description = "Local MCP server to read an IMAP mailbox." 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"]

63
mcpgen/config.go Normal file
View file

@ -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))
}

68
mcpgen/generated_test.go Normal file
View file

@ -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)
}
}

11
mcpgen/manifest.go Normal file
View file

@ -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)
}

27
mcpgen/metadata.go Normal file
View file

@ -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
}

91
mcpgen/secretstore.go Normal file
View file

@ -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
}

59
mcpgen/update.go Normal file
View file

@ -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)
}