diff --git a/README.md b/README.md index d1cd36a..d76f502 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Le binaire s’appuie maintenant sur [`mcp-framework`](../mcp-framework) pour : - `email-mcp config` : configure un profil IMAP - `email-mcp setup` : alias de compatibilité vers `config` - `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 update` : met à jour le binaire courant depuis la dernière release ## Outils MCP @@ -92,6 +93,24 @@ base_url = "https://gitea.lclr.dev" latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/latest" ``` +## Diagnostic + +`email-mcp doctor` vérifie : + +- la lisibilité du fichier de configuration +- le profil IMAP résolu +- la disponibilité du wallet système +- la présence du mot de passe stocké +- la validité du manifeste `mcp.toml` +- la connectivité IMAP avec les credentials résolus + +```sh +./email-mcp doctor +./email-mcp doctor --profile work +``` + +La commande retourne un code de sortie non nul si au moins un check échoue. + ## Installation ### Claude Code CLI diff --git a/go.mod b/go.mod index 4fc064e..484961e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module email-mcp go 1.25.0 require ( - gitea.lclr.dev/AI/mcp-framework v1.1.0 + gitea.lclr.dev/AI/mcp-framework v1.2.0-rc1 github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-message v0.18.2 github.com/godbus/dbus/v5 v5.2.2 diff --git a/go.sum b/go.sum index a4d439b..805d846 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -gitea.lclr.dev/AI/mcp-framework v1.1.0 h1:/7fqiXmhir2mDxg3s1ReKrsnFNtSaBN7eQl5zXyYcdc= -gitea.lclr.dev/AI/mcp-framework v1.1.0/go.mod h1:kUVMrL3/UBYgjOsW7sJCs3V0pO0qoJJMpIpueoTsoA4= +gitea.lclr.dev/AI/mcp-framework v1.2.0-rc1 h1:S4YZ7G9b5RI7DmgTTEzWJKsWFf9Z1guPjxyajkcNLI0= +gitea.lclr.dev/AI/mcp-framework v1.2.0-rc1/go.mod h1:kUVMrL3/UBYgjOsW7sJCs3V0pO0qoJJMpIpueoTsoA4= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= diff --git a/internal/cli/app.go b/internal/cli/app.go index 9b83a9e..8dffd95 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -113,7 +113,7 @@ func NewAppWithDependencies( func (a *App) Run(args []string) error { if len(args) == 0 { - return fmt.Errorf("usage: email-mcp ") + return fmt.Errorf("usage: email-mcp ") } switch args[0] { @@ -121,6 +121,8 @@ func (a *App) Run(args []string) error { return a.runConfig(context.Background(), args[0], args[1:]) case "mcp": return a.runMCP(context.Background(), args[1:]) + case "doctor": + return a.runDoctor(context.Background(), args[1:]) case "update": return a.runUpdate(context.Background(), args[1:]) default: diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 0f13b83..8fa6e95 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -151,6 +151,9 @@ func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) { if !strings.Contains(err.Error(), "usage:") { t.Fatalf("expected usage text in error, got %q", err.Error()) } + if !strings.Contains(err.Error(), "doctor") { + t.Fatalf("expected usage to mention doctor, got %q", err.Error()) + } } func TestAppRunConfigPromptsAndSavesProfile(t *testing.T) { @@ -383,6 +386,158 @@ latest_release_url = "http://127.0.0.1:1/releases/latest" } } +type doctorMailServiceStub struct { + listMailboxes []imapclient.Mailbox + listErr error + called bool +} + +func (s *doctorMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { + s.called = true + return s.listMailboxes, s.listErr +} + +func (s *doctorMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { + return nil, nil +} + +func (s *doctorMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { + return imapclient.Message{}, nil +} + +func TestAppRunDoctorRendersReportAndChecksConnectivity(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempHome) + t.Setenv("HOME", tempHome) + + 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] +latest_release_url = "https://example.com/releases/latest" +`), 0o600); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + secrets := &secretStoreStub{ + values: map[string]string{ + "imap-password/work": "secret", + }, + } + mail := &doctorMailServiceStub{ + listMailboxes: []imapclient.Mailbox{{Name: "INBOX"}}, + } + output := &bytes.Buffer{} + + app := NewAppWithDependencies( + nil, + store, + func() (secretStore, error) { return secrets, nil }, + func() mcpserver.MailService { return mail }, + nil, + nil, + func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil }, + nil, + output, + &bytes.Buffer{}, + "dev", + ) + + if err := app.Run([]string{"doctor"}); err != nil { + t.Fatalf("doctor returned error: %v", err) + } + if !mail.called { + t.Fatal("expected connectivity check to call mail service") + } + + text := output.String() + for _, needle := range []string{ + "[OK] config: config file is readable", + "[OK] profile: resolved profile is complete", + "[OK] password: stored password is present", + "[OK] connectivity: IMAP server is reachable", + "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) + } + } +} + +func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempHome) + t.Setenv("HOME", tempHome) + + 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, + Profiles: map[string]ProfileConfig{ + "default": { + 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] +latest_release_url = "https://example.com/releases/latest" +`), 0o600); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + output := &bytes.Buffer{} + app := NewAppWithDependencies( + nil, + store, + func() (secretStore, error) { return &secretStoreStub{}, nil }, + func() mcpserver.MailService { return &doctorMailServiceStub{} }, + 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 when password is missing") + } + 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") { + t.Fatalf("unexpected output: %q", output.String()) + } +} + func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) { app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev") @@ -392,6 +547,7 @@ func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) { }{ {command: "config", want: "config prompter is not configured"}, {command: "mcp", want: "mcp runner is not configured"}, + {command: "doctor", want: "config store is not configured"}, {command: "update", want: "manifest loader is not configured"}, } diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go new file mode 100644 index 0000000..1c3850c --- /dev/null +++ b/internal/cli/doctor.go @@ -0,0 +1,211 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "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" + frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +func (a *App) runDoctor(ctx context.Context, args []string) error { + profileFlag, err := parseProfileArgs("doctor", args) + if err != nil { + return err + } + if a.configStore == nil { + return fmt.Errorf("config store is not configured") + } + if a.openSecretStore == nil { + return fmt.Errorf("secret store is not configured") + } + if a.newMailService == nil { + return fmt.Errorf("mail service is not configured") + } + + report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{ + ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](binaryName)), + SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.frameworkSecretStoreFactory()), + ManifestDir: a.doctorManifestDir(), + ManifestValidator: func(file frameworkmanifest.File, _ string) []string { + if strings.TrimSpace(file.Update.LatestReleaseURL) == "" { + return []string{"update.latest_release_url must not be empty"} + } + return nil + }, + ConnectivityCheck: a.doctorConnectivityCheck(profileFlag), + ExtraChecks: []frameworkcli.DoctorCheck{ + a.doctorProfileCheck(profileFlag), + a.doctorPasswordCheck(profileFlag), + }, + }) + if err := frameworkcli.RenderDoctorReport(a.stdout, report); err != nil { + return err + } + if report.HasFailures() { + return fmt.Errorf("doctor checks failed") + } + return nil +} + +func (a *App) frameworkSecretStoreFactory() func() (frameworksecretstore.Store, error) { + return func() (frameworksecretstore.Store, error) { + store, err := a.openSecretStore() + if err != nil { + return nil, err + } + return store, 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) doctorProfileCheck(profileFlag string) frameworkcli.DoctorCheck { + return func(context.Context) frameworkcli.DoctorResult { + cfg, _, err := a.configStore.LoadDefault() + if err != nil { + return frameworkcli.DoctorResult{ + Name: "profile", + Status: frameworkcli.DoctorStatusFail, + Summary: "cannot load profile configuration", + Detail: err.Error(), + } + } + + profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile) + profile, ok := cfg.Profiles[profileName] + if !ok { + return frameworkcli.DoctorResult{ + Name: "profile", + Status: frameworkcli.DoctorStatusFail, + Summary: "resolved profile is missing", + Detail: fmt.Sprintf("profile %q", profileName), + } + } + + var issues []string + if strings.TrimSpace(profile.Host) == "" { + issues = append(issues, "host is empty") + } + if strings.TrimSpace(profile.Username) == "" { + issues = append(issues, "username is empty") + } + if len(issues) > 0 { + return frameworkcli.DoctorResult{ + Name: "profile", + Status: frameworkcli.DoctorStatusFail, + Summary: "resolved profile is incomplete", + Detail: fmt.Sprintf("profile %q: %s", profileName, strings.Join(issues, "; ")), + } + } + + return frameworkcli.DoctorResult{ + Name: "profile", + Status: frameworkcli.DoctorStatusOK, + Summary: "resolved profile is complete", + Detail: fmt.Sprintf("profile %q", profileName), + } + } +} + +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(), + } + } + + _, hasPassword, err := loadStoredPassword(store, profileName) + if err != nil { + return frameworkcli.DoctorResult{ + Name: "password", + Status: frameworkcli.DoctorStatusFail, + Summary: "cannot read stored password", + Detail: err.Error(), + } + } + if !hasPassword { + return frameworkcli.DoctorResult{ + Name: "password", + Status: frameworkcli.DoctorStatusFail, + Summary: "stored password is missing", + Detail: fmt.Sprintf("secret %q", passwordSecretName(profileName)), + } + } + + 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) + defer cancel() + + cred, err := a.loadCredential(profileFlag) + if err != nil { + return frameworkcli.DoctorResult{ + Name: "connectivity", + Status: frameworkcli.DoctorStatusFail, + Summary: "cannot load IMAP credentials", + Detail: err.Error(), + } + } + + if _, err := a.newMailService().ListMailboxes(ctx, cred); err != nil { + return frameworkcli.DoctorResult{ + Name: "connectivity", + Status: frameworkcli.DoctorStatusFail, + Summary: "IMAP server is unreachable or rejected authentication", + Detail: err.Error(), + } + } + + return frameworkcli.DoctorResult{ + Name: "connectivity", + Status: frameworkcli.DoctorStatusOK, + Summary: "IMAP server is reachable", + } + } +} + +func (a *App) resolveDoctorProfileName(profileFlag string) string { + envProfile := os.Getenv(defaultProfileEnv) + if a.configStore == nil { + return frameworkcli.ResolveProfileName(profileFlag, envProfile, "") + } + + cfg, _, err := a.configStore.LoadDefault() + if err != nil { + return frameworkcli.ResolveProfileName(profileFlag, envProfile, "") + } + + return frameworkcli.ResolveProfileName(profileFlag, envProfile, cfg.CurrentProfile) +}