Compare commits

..

No commits in common. "main" and "v2.5.0" have entirely different histories.
main ... v2.5.0

7 changed files with 205 additions and 23 deletions

2
go.mod
View file

@ -3,7 +3,7 @@ module email-mcp
go 1.25.0
require (
forge.lclr.dev/AI/mcp-framework v1.13.0
forge.lclr.dev/AI/mcp-framework v1.10.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

4
go.sum
View file

@ -2,10 +2,6 @@ forge.lclr.dev/AI/mcp-framework v1.9.0 h1:8i2CHQlQo/mRG1BE2UArHptAa/HC7AOhZBIqz8
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://forge.lclr.dev"
RELEASE_BASE_URL="https://gitea.lclr.dev"
RELEASE_REPOSITORY="AI/email-mcp"
INSTALLED_BINARY_PATH=""
PREFILL_SERVER_NAME=""
@ -388,13 +388,6 @@ 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

@ -137,7 +137,6 @@ func (a *App) runBootstrap(ctx context.Context, args []string) error {
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)
},

View file

@ -964,8 +964,9 @@ 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: 4 ok, 0 warning(s), 0 failure(s), 4 total",
"Summary: 6 ok, 0 warning(s), 0 failure(s), 6 total",
} {
if !strings.Contains(text, needle) {
t.Fatalf("output = %q, want substring %q", text, needle)
@ -995,6 +996,16 @@ 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,
@ -1003,7 +1014,7 @@ func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) {
func() mcpserver.MailService { return &doctorMailServiceStub{} },
nil,
nil,
nil,
func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
nil,
output,
&bytes.Buffer{},
@ -1017,7 +1028,7 @@ func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) {
if !strings.Contains(err.Error(), "doctor checks failed") {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(output.String(), "[FAIL] connectivity: cannot load IMAP credentials") {
if !strings.Contains(output.String(), "[FAIL] password: stored password is missing") {
t.Fatalf("unexpected output: %q", output.String())
}
}
@ -1046,6 +1057,16 @@ 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,
@ -1054,7 +1075,7 @@ func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) {
func() mcpserver.MailService { return &doctorMailServiceStub{} },
nil,
nil,
nil,
func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
nil,
output,
&bytes.Buffer{},
@ -1064,11 +1085,78 @@ func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) {
if err := app.Run([]string{"doctor"}); err != nil {
t.Fatalf("doctor returned error: %v", err)
}
if !strings.Contains(output.String(), "[OK] connectivity: IMAP server is reachable") {
if !strings.Contains(output.String(), "[OK] password: password is provided via environment") {
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")

View file

@ -2,13 +2,19 @@ package cli
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"email-mcp/mcpgen"
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
frameworkupdate "forge.lclr.dev/AI/mcp-framework/update"
)
func (a *App) runDoctor(ctx context.Context, args []string) error {
@ -26,12 +32,18 @@ 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](mcpgen.BinaryName)),
SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.openSecretStore),
ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)),
SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.openSecretStore),
ManifestDir: a.doctorManifestDir(),
ManifestValidator: func(file frameworkmanifest.File, _ string) []string {
return validateManifestUpdate(file, metadata.BinaryName)
},
ConnectivityCheck: a.doctorConnectivityCheck(profileFlag),
ExtraChecks: []frameworkcli.DoctorCheck{
a.doctorRequiredProfileFieldsCheck(profileFlag),
a.doctorPasswordCheck(profileFlag),
},
})
if err := frameworkcli.RenderDoctorReport(a.stdout, report); err != nil {
@ -43,6 +55,18 @@ 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
@ -84,6 +108,66 @@ 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,
passwordOnlyFieldSpecs(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)
@ -129,3 +213,25 @@ 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

@ -1,11 +1,11 @@
binary_name = "email-mcp"
docs_url = "https://forge.lclr.dev/AI/email-mcp"
docs_url = "https://gitea.lclr.dev/AI/email-mcp"
[update]
source_name = "email-mcp releases"
driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://forge.lclr.dev"
base_url = "https://gitea.lclr.dev"
asset_name_template = "{binary}-{os}-{arch}{ext}"
checksum_asset_name = "{asset}.sha256"
checksum_required = true