Compare commits

...

12 commits
v2.4.0 ... main

Author SHA1 Message Date
0de2a2b351 chore: migrate forge URLs from gitea.lclr.dev to forge.lclr.dev
All checks were successful
Release / release (push) Successful in 26s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:17:26 +02:00
72fb48f59e chore: upgrade framework to v1.13.0
All checks were successful
Release / release (push) Successful in 26s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:02:38 +02:00
23fff88ccb refactor(doctor): drop redundant password check
All checks were successful
Release / release (push) Successful in 27s
Le check "password" ouvrait le store et lisait le secret indépendamment
du ConnectivityCheck, provoquant deux appels bw --version + bw status.
La connectivité échoue déjà si le mot de passe est inaccessible, donc
le check était redondant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:51:48 +02:00
235727106d chore: upgrade framework to v1.12.0, wire login, drop manifest glue
- Bump mcp-framework v1.10.0 → v1.12.0
- Wire Login hook via BitwardenLoginHandler (backend_policy = bitwarden-cli)
- Remove ManifestDir/ManifestValidator from config test: le manifest est
  un artefact de build, pas une contrainte runtime (per framework design)
- Drop doctorManifestDir(), validateManifestUpdate() and dead imports
- Update tests accordingly (5 checks instead of 6, remove manifest test)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:39:39 +02:00
1e0bdfc42d feat(install): unlock Bitwarden before setup
Calls `email-mcp login` before `email-mcp setup` so the secret store
is ready to persist the IMAP password.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:33:43 +02:00
51dda82cd0 chore: update release base URL to forge.lclr.dev 2026-05-11 12:21:55 +02:00
fbaea341c7 chore: upgrade framework to v1.10.0, drop login glue
All checks were successful
Release / release (push) Successful in 36s
login is now handled by default in the framework bootstrap when
Hooks.Login is nil.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:16:20 +02:00
ebd3a64522 feat: wire login hook for Bitwarden unlock
The framework v1.9.0 added a Login command/hook to authenticate and
unlock Bitwarden. App.runBootstrap was not registering it, causing
"command not configured: login".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:36:12 +02:00
801d7fbb95 fix: preserve bitwarden backend errors 2026-05-11 11:29:25 +02:00
fe9e70b61a fix: use bitwarden secret backend 2026-05-11 11:24:56 +02:00
be2b7e631b feat: upgrade mcp framework generation 2026-05-11 11:16:37 +02:00
be33b467a6 migrate gitea > forgejo 2026-05-11 11:06:48 +02:00
19 changed files with 518 additions and 355 deletions

View file

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

View file

@ -6,8 +6,9 @@ Le binaire sappuie maintenant sur [`mcp-framework`](../mcp-framework) pour :
- la gestion de profils CLI
- 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 Bitwarden via `bw`
- le manifeste `mcp.toml`
- les helpers Go générés depuis `mcp.toml` (`mcpgen/`)
- lauto-update via `email-mcp update`
## Commandes
@ -17,7 +18,7 @@ Le binaire sappuie maintenant sur [`mcp-framework`](../mcp-framework) pour :
- `email-mcp config test` : lance les checks de configuration/connectivité (équivalent de `doctor`)
- `email-mcp config delete` : supprime un profil local et son mot de passe stocké
- `email-mcp mcp` : lance le serveur MCP sur `stdin/stdout`
- `email-mcp doctor` : diagnostique la configuration locale, le wallet, le manifeste et laccès IMAP
- `email-mcp doctor` : diagnostique la configuration locale, Bitwarden, le manifeste et laccès IMAP
- `email-mcp update` : met à jour le binaire courant depuis la dernière release
- `email-mcp version` : affiche la version du binaire
@ -34,7 +35,7 @@ La commande `email-mcp help` (ou `-h` / `--help`) affiche laide globale.
La configuration est séparée en deux parties :
- `host` et `username` sont stockés dans `config.json`
- `password` est stocké dans le wallet système
- `password` est stocké dans Bitwarden via le CLI `bw`
Le profil actif est résolu dans cet ordre :
@ -44,11 +45,11 @@ 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`
3. `password` : `EMAIL_MCP_PASSWORD` puis secret wallet `imap-password/<profile>`
3. `password` : `EMAIL_MCP_PASSWORD` puis secret Bitwarden `imap-password/<profile>`
### Configurer un profil
@ -68,9 +69,9 @@ Le binaire demande ensuite :
2. le nom dutilisateur
3. le mot de passe
Si un mot de passe existe déjà dans le wallet, laisser le champ vide le conserve.
Si un mot de passe existe déjà dans Bitwarden, laisser le champ vide le conserve.
Si le backend de secrets est en lecture seule (`[secret_store].backend_policy = "env-only"`), `setup` ne peut pas persister le mot de passe dans un wallet. Dans ce cas, exporte `EMAIL_MCP_PASSWORD` avant `setup`. La commande sauvegarde alors `host`/`username` et utilise le mot de passe depuis lenvironnement.
Le backend de secrets déclaré dans `mcp.toml` est `bitwarden-cli`. Le CLI `bw` doit être installé, connecté et déverrouillé avec `BW_SESSION` disponible dans lenvironnement. `EMAIL_MCP_PASSWORD` reste accepté pour fournir le mot de passe sans lire Bitwarden.
### Lancer le serveur MCP
@ -134,7 +135,7 @@ token_env_names = ["GITEA_TOKEN"]
- la lisibilité du fichier de configuration
- le profil IMAP résolu
- la disponibilité du wallet système
- la disponibilité du backend Bitwarden
- la présence du mot de passe stocké
- la validité du manifeste `mcp.toml`
- la connectivité IMAP avec les credentials résolus
@ -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
```

2
go.mod
View file

@ -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.13.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

10
go.sum
View file

@ -1,5 +1,11 @@
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=
forge.lclr.dev/AI/mcp-framework v1.10.0 h1:RrTy7K/hSruaVS9Z/oaRpkLs2U5WGs4H3tox7PiErak=
forge.lclr.dev/AI/mcp-framework v1.10.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
forge.lclr.dev/AI/mcp-framework v1.12.0 h1:pu1cfWcL62BF+f7DBe4IbkigHLcK6YOJ3vEBz1495AY=
forge.lclr.dev/AI/mcp-framework v1.12.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
forge.lclr.dev/AI/mcp-framework v1.13.0 h1:YfC/AqdzTHGRgtZxMl7CfDN+duFezyQ4nkX9uTD+HX0=
forge.lclr.dev/AI/mcp-framework v1.13.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=

View file

@ -4,7 +4,7 @@ set -euo pipefail
BINARY_NAME="email-mcp"
DEFAULT_PROFILE="default"
PROFILE_ENV="EMAIL_MCP_PROFILE"
RELEASE_BASE_URL="https://gitea.lclr.dev"
RELEASE_BASE_URL="https://forge.lclr.dev"
RELEASE_REPOSITORY="AI/email-mcp"
INSTALLED_BINARY_PATH=""
PREFILL_SERVER_NAME=""
@ -388,6 +388,13 @@ run_setup_wizard() {
exit 1
fi
ui_info "Déverrouillage Bitwarden avant le setup..."
if [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
env "${PROFILE_ENV}=${profile}" "$binary_path" login < /dev/tty > /dev/tty
else
env "${PROFILE_ENV}=${profile}" "$binary_path" login
fi
ui_info "Lancement de $BINARY_NAME setup"
if [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty

View file

@ -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,18 +125,19 @@ 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)
},
Login: frameworkbootstrap.BitwardenLoginHandler(metadata.BinaryName),
MCP: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runMCP(ctx, inv.Args)
},
@ -264,7 +265,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 +394,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 +441,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 +466,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 +538,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 +590,8 @@ type runtimeMetadata struct {
func (a *App) runtimeMetadata() runtimeMetadata {
metadata := runtimeMetadata{
BinaryName: binaryName,
Description: binaryDescription,
BinaryName: mcpgen.BinaryName,
Description: mcpgen.DefaultDescription,
DefaultProfile: fallbackProfile,
}
@ -731,10 +688,7 @@ func mapAppError(err error) error {
case errors.Is(err, mcpserver.ErrCredentialsNotConfigured):
return newUserFacingError("credentials not configured; run `email-mcp setup`", err)
case errors.Is(err, frameworksecretstore.ErrBackendUnavailable):
return newUserFacingError(
fmt.Sprintf("%s is not available; configure a supported OS wallet and retry", frameworksecretstore.BackendName()),
err,
)
return newUserFacingError(strings.TrimSpace(err.Error()), err)
case errors.Is(err, frameworksecretstore.ErrReadOnly):
return newUserFacingError("secret backend is read-only", err)
default:

View file

@ -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"
@ -964,9 +964,8 @@ base_url = "https://gitea.lclr.dev"
for _, needle := range []string{
"[OK] config: config file is readable",
"[OK] profile: required profile values are resolved",
"[OK] password: stored password is present",
"[OK] connectivity: IMAP server is reachable",
"Summary: 6 ok, 0 warning(s), 0 failure(s), 6 total",
"Summary: 4 ok, 0 warning(s), 0 failure(s), 4 total",
} {
if !strings.Contains(text, needle) {
t.Fatalf("output = %q, want substring %q", text, needle)
@ -996,16 +995,6 @@ func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) {
t.Fatalf("Save returned error: %v", err)
}
manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update]
driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev"
`), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
output := &bytes.Buffer{}
app := NewAppWithDependencies(
nil,
@ -1014,7 +1003,7 @@ base_url = "https://gitea.lclr.dev"
func() mcpserver.MailService { return &doctorMailServiceStub{} },
nil,
nil,
func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
nil,
nil,
output,
&bytes.Buffer{},
@ -1028,7 +1017,7 @@ base_url = "https://gitea.lclr.dev"
if !strings.Contains(err.Error(), "doctor checks failed") {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(output.String(), "[FAIL] password: stored password is missing") {
if !strings.Contains(output.String(), "[FAIL] connectivity: cannot load IMAP credentials") {
t.Fatalf("unexpected output: %q", output.String())
}
}
@ -1057,16 +1046,6 @@ func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) {
t.Fatalf("Save returned error: %v", err)
}
manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update]
driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev"
`), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
output := &bytes.Buffer{}
app := NewAppWithDependencies(
nil,
@ -1075,7 +1054,7 @@ base_url = "https://gitea.lclr.dev"
func() mcpserver.MailService { return &doctorMailServiceStub{} },
nil,
nil,
func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
nil,
nil,
output,
&bytes.Buffer{},
@ -1085,78 +1064,11 @@ base_url = "https://gitea.lclr.dev"
if err := app.Run([]string{"doctor"}); err != nil {
t.Fatalf("doctor returned error: %v", err)
}
if !strings.Contains(output.String(), "[OK] password: password is provided via environment") {
if !strings.Contains(output.String(), "[OK] connectivity: IMAP server is reachable") {
t.Fatalf("unexpected output: %q", output.String())
}
}
func TestAppRunDoctorFailsWhenManifestUpdateConfigIsInvalid(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tempHome)
t.Setenv("HOME", tempHome)
t.Setenv(passwordEnv, "env-secret")
store := frameworkconfig.NewStore[ProfileConfig](binaryName)
configPath, err := store.ConfigPath()
if err != nil {
t.Fatalf("ConfigPath returned error: %v", err)
}
if err := store.Save(configPath, frameworkconfig.FileConfig[ProfileConfig]{
Version: frameworkconfig.CurrentVersion,
CurrentProfile: "work",
Profiles: map[string]ProfileConfig{
"work": {
Host: "imap.example.com",
Username: "alice",
},
},
}); err != nil {
t.Fatalf("Save returned error: %v", err)
}
manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update]
driver = "gitea"
base_url = "https://gitea.lclr.dev"
`), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
mail := &doctorMailServiceStub{
listMailboxes: []imapclient.Mailbox{{Name: "INBOX"}},
}
output := &bytes.Buffer{}
app := NewAppWithDependencies(
nil,
store,
func() (secretStore, error) { return &secretStoreStub{}, nil },
func() mcpserver.MailService { return mail },
nil,
nil,
func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
nil,
output,
&bytes.Buffer{},
"dev",
)
err = app.Run([]string{"doctor"})
if err == nil {
t.Fatal("expected doctor to fail with invalid manifest update config")
}
if !strings.Contains(err.Error(), "doctor checks failed") {
t.Fatalf("unexpected error: %v", err)
}
text := output.String()
if !strings.Contains(text, "[FAIL] manifest: manifest validation failed") {
t.Fatalf("unexpected output: %q", text)
}
if !strings.Contains(text, "requires repository") {
t.Fatalf("unexpected output: %q", text)
}
}
func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev")
@ -1197,7 +1109,7 @@ func TestMapAppErrorMapsMissingCredentialError(t *testing.T) {
}
}
func TestMapAppErrorMapsUnavailableWalletError(t *testing.T) {
func TestMapAppErrorMapsUnavailableSecretBackendError(t *testing.T) {
err := mapAppError(&frameworksecretstore.BackendUnavailableError{
Policy: frameworksecretstore.BackendAuto,
Required: "any keyring backend",
@ -1205,8 +1117,25 @@ func TestMapAppErrorMapsUnavailableWalletError(t *testing.T) {
if err == nil {
t.Fatal("expected mapped error")
}
if !strings.Contains(strings.ToLower(err.Error()), "wallet") {
t.Fatalf("expected wallet guidance, got %v", err)
if !strings.Contains(strings.ToLower(err.Error()), "secret backend") {
t.Fatalf("expected secret backend guidance, got %v", err)
}
}
func TestMapAppErrorPreservesBitwardenBackendDetails(t *testing.T) {
err := mapAppError(fmt.Errorf(
"cannot use bitwarden CLI command %q right now: %w",
"bw",
errors.Join(frameworksecretstore.ErrBackendUnavailable, frameworksecretstore.ErrBWLocked),
))
if err == nil {
t.Fatal("expected mapped error")
}
if !strings.Contains(strings.ToLower(err.Error()), "bitwarden") {
t.Fatalf("expected bitwarden guidance, got %v", err)
}
if strings.Contains(strings.ToLower(err.Error()), "secret service") || strings.Contains(strings.ToLower(err.Error()), "kwallet") {
t.Fatalf("unexpected keyring guidance: %v", err)
}
}
@ -1234,7 +1163,7 @@ func TestExecuteSetupWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) {
if code := Execute(app, []string{"setup"}, stderr); code != 1 {
t.Fatalf("expected exit code 1, got %d", code)
}
if got := strings.ToLower(stderr.String()); !strings.Contains(got, "wallet") {
if got := strings.ToLower(stderr.String()); !strings.Contains(got, "secret backend") {
t.Fatalf("unexpected stderr: %q", got)
}
}

View file

@ -2,18 +2,13 @@ package cli
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"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"
)
func (a *App) runDoctor(ctx context.Context, args []string) error {
@ -31,18 +26,12 @@ func (a *App) runDoctor(ctx context.Context, args []string) error {
return fmt.Errorf("mail service is not configured")
}
metadata := a.runtimeMetadata()
report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{
ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](binaryName)),
SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.openSecretStore),
ManifestDir: a.doctorManifestDir(),
ManifestValidator: func(file frameworkmanifest.File, _ string) []string {
return validateManifestUpdate(file, metadata.BinaryName)
},
ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)),
SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.openSecretStore),
ConnectivityCheck: a.doctorConnectivityCheck(profileFlag),
ExtraChecks: []frameworkcli.DoctorCheck{
a.doctorRequiredProfileFieldsCheck(profileFlag),
a.doctorPasswordCheck(profileFlag),
},
})
if err := frameworkcli.RenderDoctorReport(a.stdout, report); err != nil {
@ -54,18 +43,6 @@ func (a *App) runDoctor(ctx context.Context, args []string) error {
return nil
}
func (a *App) doctorManifestDir() string {
if a.resolveExecutable == nil {
return "."
}
executablePath, err := a.resolveExecutable()
if err != nil {
return "."
}
return filepath.Dir(executablePath)
}
func (a *App) doctorRequiredProfileFieldsCheck(profileFlag string) frameworkcli.DoctorCheck {
var (
profileValues map[string]string
@ -73,7 +50,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) {
@ -107,66 +84,6 @@ func (a *App) doctorRequiredProfileFieldsCheck(profileFlag string) frameworkcli.
}
}
func (a *App) doctorPasswordCheck(profileFlag string) frameworkcli.DoctorCheck {
return func(context.Context) frameworkcli.DoctorResult {
profileName := a.resolveDoctorProfileName(profileFlag)
store, err := a.openSecretStore()
if err != nil {
return frameworkcli.DoctorResult{
Name: "password",
Status: frameworkcli.DoctorStatusFail,
Summary: "cannot inspect stored password",
Detail: err.Error(),
}
}
resolution, err := resolveCredentialFields(
ProfileConfig{},
store,
[]frameworkcli.FieldSpec{passwordFieldSpec(profileName)},
)
if err != nil {
var missingErr *frameworkcli.MissingRequiredValuesError
if errors.As(err, &missingErr) {
return frameworkcli.DoctorResult{
Name: "password",
Status: frameworkcli.DoctorStatusFail,
Summary: "stored password is missing",
Detail: fmt.Sprintf(
"set %q or secret %q",
passwordEnv,
passwordSecretName(profileName),
),
}
}
return frameworkcli.DoctorResult{
Name: "password",
Status: frameworkcli.DoctorStatusFail,
Summary: "cannot read stored password",
Detail: err.Error(),
}
}
password, _ := resolution.Get("password")
if password.Source == frameworkcli.SourceEnv {
return frameworkcli.DoctorResult{
Name: "password",
Status: frameworkcli.DoctorStatusOK,
Summary: "password is provided via environment",
Detail: fmt.Sprintf("variable %q", passwordEnv),
}
}
return frameworkcli.DoctorResult{
Name: "password",
Status: frameworkcli.DoctorStatusOK,
Summary: "stored password is present",
Detail: fmt.Sprintf("secret %q", passwordSecretName(profileName)),
}
}
}
func (a *App) doctorConnectivityCheck(profileFlag string) frameworkcli.DoctorCheck {
return func(parent context.Context) frameworkcli.DoctorResult {
ctx, cancel := context.WithTimeout(parent, 35*time.Second)
@ -212,25 +129,3 @@ func (a *App) resolveDoctorProfileName(profileFlag string) string {
return a.resolveProfileName(profileFlag, cfg.CurrentProfile)
}
func validateManifestUpdate(file frameworkmanifest.File, runtimeBinaryName string) []string {
source := file.Update.ReleaseSource()
issues := make([]string, 0, 2)
if _, err := frameworkupdate.ResolveLatestReleaseURL("", source); err != nil {
issues = append(issues, err.Error())
}
binary := strings.TrimSpace(runtimeBinaryName)
if binary == "" {
binary = strings.TrimSpace(file.BinaryName)
}
if binary == "" {
binary = binaryName
}
if _, err := frameworkupdate.AssetNameWithTemplate(binary, runtime.GOOS, runtime.GOARCH, source.AssetNameTemplate); err != nil {
issues = append(issues, err.Error())
}
return issues
}

View file

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

View file

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

View file

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

View file

@ -1,21 +1,29 @@
binary_name = "email-mcp"
docs_url = "https://gitea.lclr.dev/AI/email-mcp"
docs_url = "https://forge.lclr.dev/AI/email-mcp"
[update]
source_name = "email-mcp releases"
driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev"
base_url = "https://forge.lclr.dev"
asset_name_template = "{binary}-{os}-{arch}{ext}"
checksum_asset_name = "{asset}.sha256"
checksum_required = true
token_env_names = ["GITEA_TOKEN"]
[environment]
known = ["EMAIL_MCP_PROFILE", "EMAIL_MCP_HOST", "EMAIL_MCP_USERNAME", "EMAIL_MCP_PASSWORD"]
known = [
"EMAIL_MCP_PROFILE",
"EMAIL_MCP_HOST",
"EMAIL_MCP_USERNAME",
"EMAIL_MCP_PASSWORD",
"BW_SESSION",
"MCP_FRAMEWORK_BITWARDEN_CACHE",
"MCP_FRAMEWORK_BITWARDEN_DEBUG",
]
[secret_store]
backend_policy = "auto"
backend_policy = "bitwarden-cli"
[profiles]
default = "default"
@ -23,3 +31,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"]

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

71
mcpgen/generated_test.go Normal file
View file

@ -0,0 +1,71 @@
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))
}
if manifestFile.SecretStore.BackendPolicy != "bitwarden-cli" {
t.Fatalf("secret store backend policy = %q, want bitwarden-cli", manifestFile.SecretStore.BackendPolicy)
}
}
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 = [\n \"EMAIL_MCP_PROFILE\",\n \"EMAIL_MCP_HOST\",\n \"EMAIL_MCP_USERNAME\",\n \"EMAIL_MCP_PASSWORD\",\n \"BW_SESSION\",\n \"MCP_FRAMEWORK_BITWARDEN_CACHE\",\n \"MCP_FRAMEWORK_BITWARDEN_DEBUG\",\n]\n\n[secret_store]\nbackend_policy = \"bitwarden-cli\"\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)
}