Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0de2a2b351 | |||
| 72fb48f59e | |||
| 23fff88ccb | |||
| 235727106d | |||
| 1e0bdfc42d | |||
| 51dda82cd0 | |||
| fbaea341c7 | |||
| ebd3a64522 | |||
| 801d7fbb95 | |||
| fe9e70b61a | |||
| be2b7e631b | |||
| be33b467a6 | |||
| a87db60345 | |||
| 9b90332b7c |
19 changed files with 518 additions and 355 deletions
10
Makefile
10
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
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -6,8 +6,9 @@ Le binaire s’appuie 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 l’OS
|
||||
- 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/`)
|
||||
- l’auto-update via `email-mcp update`
|
||||
|
||||
## Commandes
|
||||
|
|
@ -17,7 +18,7 @@ Le binaire s’appuie 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 l’accès IMAP
|
||||
- `email-mcp doctor` : diagnostique la configuration locale, Bitwarden, le manifeste et l’accè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 l’aide 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 d’utilisateur
|
||||
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 l’environnement.
|
||||
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 l’environnement. `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
2
go.mod
|
|
@ -3,7 +3,7 @@ module email-mcp
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
gitea.lclr.dev/AI/mcp-framework v1.4.0-rc3
|
||||
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
10
go.sum
|
|
@ -1,5 +1,11 @@
|
|||
gitea.lclr.dev/AI/mcp-framework v1.4.0-rc3 h1:DlJPkJkRA5fjKBXEFkHHb93M4lxireksJv07zM/oiA8=
|
||||
gitea.lclr.dev/AI/mcp-framework v1.4.0-rc3/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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
43
mcp.toml
43
mcp.toml
|
|
@ -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
63
mcpgen/config.go
Normal 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
71
mcpgen/generated_test.go
Normal 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
11
mcpgen/manifest.go
Normal 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
27
mcpgen/metadata.go
Normal 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
91
mcpgen/secretstore.go
Normal 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
59
mcpgen/update.go
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue