diff --git a/README.md b/README.md index 89118bc..3e0a177 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Le binaire s’appuie maintenant sur [`mcp-framework`](../mcp-framework) pour : - `email-mcp setup` : configure (ou met à jour) un profil IMAP - `email-mcp config show` : affiche la configuration IMAP résolue et la provenance - `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 update` : met à jour le binaire courant depuis la dernière release @@ -40,7 +41,8 @@ Le profil actif est résolu dans cet ordre : 1. `--profile` 2. `EMAIL_MCP_PROFILE` 3. `current_profile` dans `config.json` -4. `default` +4. `[profiles].default` dans `mcp.toml` +5. `default` Les credentials IMAP sont résolus ensuite via le résolveur multi-sources du framework (RC3) : @@ -111,6 +113,8 @@ credentials not configured; run `email-mcp setup` Le manifeste de ce repo pointe vers l’endpoint Gitea : ```toml +binary_name = "email-mcp" + [update] source_name = "email-mcp releases" base_url = "https://gitea.lclr.dev" diff --git a/internal/cli/app.go b/internal/cli/app.go index fec561e..2bdd913 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" frameworkbootstrap "gitea.lclr.dev/AI/mcp-framework/bootstrap" @@ -28,6 +29,7 @@ const ( usernameEnv = "EMAIL_MCP_USERNAME" passwordEnv = "EMAIL_MCP_PASSWORD" binaryDescription = "Local MCP server to read an IMAP mailbox." + fallbackProfile = "default" ) type MCPRunner interface { @@ -133,9 +135,11 @@ func (a *App) Run(args []string) error { } func (a *App) runBootstrap(ctx context.Context, args []string) error { + metadata := a.runtimeMetadata() + return frameworkbootstrap.Run(ctx, frameworkbootstrap.Options{ - BinaryName: binaryName, - Description: binaryDescription, + BinaryName: metadata.BinaryName, + Description: metadata.Description, Version: a.version, Args: args, Stdin: a.stdin, @@ -154,6 +158,9 @@ func (a *App) runBootstrap(ctx context.Context, args []string) error { ConfigTest: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { return a.runConfigTest(ctx, inv.Args) }, + ConfigDelete: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { + return a.runConfigDelete(ctx, inv.Args) + }, Update: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { return a.runUpdate(ctx, inv.Args) }, @@ -195,10 +202,12 @@ func isDoctorHelpCommand(args []string) bool { } func (a *App) printGlobalHelp() error { - if _, err := fmt.Fprintf(a.stdout, "%s\n\n", binaryDescription); err != nil { + metadata := a.runtimeMetadata() + + if _, err := fmt.Fprintf(a.stdout, "%s\n\n", metadata.Description); err != nil { return err } - if _, err := fmt.Fprintf(a.stdout, "Usage:\n %s [args]\n\n", binaryName); err != nil { + if _, err := fmt.Fprintf(a.stdout, "Usage:\n %s [args]\n\n", metadata.BinaryName); err != nil { return err } if _, err := fmt.Fprintln(a.stdout, "Common commands:"); err != nil { @@ -222,15 +231,17 @@ func (a *App) printGlobalHelp() error { } } - _, err := fmt.Fprintf(a.stdout, "\nDetailed help: %s help \n", binaryName) + _, err := fmt.Fprintf(a.stdout, "\nDetailed help: %s help \n", metadata.BinaryName) return err } func (a *App) printDoctorHelp() error { + metadata := a.runtimeMetadata() + _, err := fmt.Fprintf( a.stdout, "Usage:\n %s doctor [--profile NAME]\n\nRun local diagnostics for config, wallet, manifest, and IMAP connectivity.\n", - binaryName, + metadata.BinaryName, ) return err } @@ -256,7 +267,7 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro return err } - profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile) + profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile) profile := cfg.Profiles[profileName] secrets, err := a.openSecretStore() @@ -320,7 +331,7 @@ func (a *App) runConfigShow(ctx context.Context, args []string) error { return err } - profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile) + profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile) profile := cfg.Profiles[profileName] secrets, err := a.openSecretStore() @@ -360,6 +371,56 @@ func (a *App) runConfigTest(ctx context.Context, args []string) error { return a.runDoctor(ctx, args) } +func (a *App) runConfigDelete(_ context.Context, args []string) error { + if a.configStore == nil { + return fmt.Errorf("config store is not configured") + } + if a.openSecretStore == nil { + return fmt.Errorf("secret store is not configured") + } + + profileFlag, err := parseProfileArgs("config delete", args) + if err != nil { + return err + } + + cfg, _, err := a.configStore.LoadDefault() + if err != nil { + return err + } + + profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile) + secrets, err := a.openSecretStore() + if err != nil { + return mapAppError(err) + } + if err := secrets.DeleteSecret(passwordSecretName(profileName)); err != nil && !errors.Is(err, frameworksecretstore.ErrNotFound) { + return mapAppError(err) + } + + if cfg.Profiles != nil { + delete(cfg.Profiles, profileName) + } + if strings.TrimSpace(cfg.CurrentProfile) == profileName { + cfg.CurrentProfile = nextCurrentProfile(cfg.Profiles, a.runtimeMetadata().DefaultProfile) + } + + configPath, err := a.configStore.SaveDefault(cfg) + if err != nil { + return err + } + + if _, err := fmt.Fprintf(a.stdout, "profile %q deleted from %s\n", profileName, configPath); err != nil { + return err + } + if cfg.CurrentProfile != "" { + if _, err := fmt.Fprintf(a.stdout, "current profile: %s\n", cfg.CurrentProfile); err != nil { + return err + } + } + return nil +} + func (a *App) runMCP(ctx context.Context, args []string) error { if a.newRunner == nil { return fmt.Errorf("mcp runner is not configured") @@ -411,7 +472,7 @@ func (a *App) runUpdate(ctx context.Context, args []string) error { return frameworkupdate.Run(ctx, frameworkupdate.Options{ CurrentVersion: a.version, ExecutablePath: executablePath, - BinaryName: binaryName, + BinaryName: a.runtimeMetadata().BinaryName, ReleaseSource: manifestFile.Update.ReleaseSource(), Stdout: a.stdout, }) @@ -447,7 +508,7 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error) return secretstore.Credential{}, err } - profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile) + profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile) profile := cfg.Profiles[profileName] secrets, err := a.openSecretStore() @@ -648,6 +709,103 @@ func parseUpdateArgs(args []string) error { return nil } +type runtimeMetadata struct { + BinaryName string + Description string + DefaultProfile string +} + +func (a *App) runtimeMetadata() runtimeMetadata { + metadata := runtimeMetadata{ + BinaryName: binaryName, + Description: binaryDescription, + DefaultProfile: fallbackProfile, + } + + if a.loadManifest == nil { + return metadata + } + + file, err := a.loadRuntimeManifest() + if err != nil { + return metadata + } + + bootstrap := file.BootstrapInfo() + if bootstrap.BinaryName != "" { + metadata.BinaryName = bootstrap.BinaryName + } + if bootstrap.Description != "" { + metadata.Description = bootstrap.Description + } + if bootstrap.DefaultProfile != "" { + metadata.DefaultProfile = bootstrap.DefaultProfile + } + + return metadata +} + +func (a *App) loadRuntimeManifest() (frameworkmanifest.File, error) { + if a.loadManifest == nil { + return frameworkmanifest.File{}, fmt.Errorf("manifest loader is not configured") + } + + if a.resolveExecutable != nil { + executablePath, err := a.resolveExecutable() + if err == nil { + file, loadErr := a.loadManifestForExecutable(executablePath) + if loadErr == nil { + return file, nil + } + } + } + + file, _, err := a.loadManifest(".") + if err != nil { + return frameworkmanifest.File{}, err + } + return file, nil +} + +func (a *App) resolveProfileName(profileFlag, currentProfile string) string { + resolvedCurrent := strings.TrimSpace(currentProfile) + if resolvedCurrent == "" { + resolvedCurrent = strings.TrimSpace(a.runtimeMetadata().DefaultProfile) + } + + return frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), resolvedCurrent) +} + +func nextCurrentProfile(profiles map[string]ProfileConfig, preferred string) string { + if len(profiles) == 0 { + return "" + } + + normalizedPreferred := strings.TrimSpace(preferred) + if normalizedPreferred != "" { + if _, ok := profiles[normalizedPreferred]; ok { + return normalizedPreferred + } + } + + if _, ok := profiles[fallbackProfile]; ok { + return fallbackProfile + } + + names := make([]string, 0, len(profiles)) + for name := range profiles { + if trimmed := strings.TrimSpace(name); trimmed != "" { + names = append(names, trimmed) + } + } + if len(names) == 0 { + return "" + } + + sort.Strings(names) + return names[0] +} + func mapAppError(err error) error { if err == nil { return nil diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index fe6da00..891f118 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -87,9 +87,12 @@ type secretStoreStub struct { values map[string]string setErr error getErr error + deleteErr error setName string setValue string setCalled bool + delName string + delCalled bool } func (s *secretStoreStub) SetSecret(name, _ string, secret string) error { @@ -118,6 +121,11 @@ func (s *secretStoreStub) GetSecret(name string) (string, error) { } func (s *secretStoreStub) DeleteSecret(name string) error { + s.delCalled = true + s.delName = name + if s.deleteErr != nil { + return s.deleteErr + } delete(s.values, name) return nil } @@ -157,6 +165,41 @@ func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) { } } +func TestAppRunShowsManifestBootstrapMetadataInHelp(t *testing.T) { + output := &bytes.Buffer{} + app := NewAppWithDependencies( + nil, + nil, + nil, + nil, + nil, + func(string) (frameworkmanifest.File, string, error) { + return frameworkmanifest.File{ + BinaryName: "email-mcp-custom", + Bootstrap: frameworkmanifest.Bootstrap{ + Description: "Custom manifest description", + }, + }, "/tmp/mcp.toml", nil + }, + func() (string, error) { return "/tmp/bin/email-mcp-custom", nil }, + nil, + output, + &bytes.Buffer{}, + "dev", + ) + + if err := app.Run(nil); err != nil { + t.Fatalf("expected help to be rendered, got error %v", err) + } + + text := output.String() + for _, snippet := range []string{"Custom manifest description", "email-mcp-custom ", "email-mcp-custom help "} { + if !strings.Contains(text, snippet) { + t.Fatalf("help output missing %q: %q", snippet, text) + } + } +} + func TestAppRunVersionPrintsBuildVersion(t *testing.T) { output := &bytes.Buffer{} app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "v1.2.3") @@ -234,6 +277,56 @@ func TestAppRunSetupPromptsAndSavesProfile(t *testing.T) { } } +func TestAppRunSetupUsesManifestDefaultProfile(t *testing.T) { + prompter := &configPrompterStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "secret", + }, + } + cfgStore := &configStoreStub{} + secrets := &secretStoreStub{} + output := &bytes.Buffer{} + + app := NewAppWithDependencies( + prompter, + cfgStore, + func() (secretStore, error) { return secrets, nil }, + nil, + nil, + func(string) (frameworkmanifest.File, string, error) { + return frameworkmanifest.File{ + Profiles: frameworkmanifest.Profiles{ + Default: "work", + }, + }, "/tmp/mcp.toml", nil + }, + func() (string, error) { return "/tmp/bin/email-mcp", nil }, + nil, + output, + &bytes.Buffer{}, + "dev", + ) + + if err := app.Run([]string{"setup"}); err != nil { + t.Fatalf("Run returned error: %v", err) + } + + if !secrets.setCalled { + t.Fatal("expected password to be stored") + } + if secrets.setName != "imap-password/work" { + t.Fatalf("unexpected secret name %q", secrets.setName) + } + if cfgStore.saved.CurrentProfile != "work" { + t.Fatalf("current profile = %q, want work", cfgStore.saved.CurrentProfile) + } + if got := output.String(); !strings.Contains(got, `profile "work" saved`) { + t.Fatalf("unexpected output %q", got) + } +} + func TestAppRunConfigRequiresSubcommand(t *testing.T) { prompter := &configPrompterStub{ credential: secretstore.Credential{ @@ -271,6 +364,69 @@ func TestAppRunConfigRequiresSubcommand(t *testing.T) { } } +func TestAppRunConfigDeleteRemovesProfileAndSecret(t *testing.T) { + cfgStore := &configStoreStub{ + cfg: frameworkconfig.FileConfig[ProfileConfig]{ + Version: frameworkconfig.CurrentVersion, + CurrentProfile: "work", + Profiles: map[string]ProfileConfig{ + "work": { + Host: "imap.work.example.com", + Username: "alice", + }, + "default": { + Host: "imap.default.example.com", + Username: "alice", + }, + }, + }, + } + secrets := &secretStoreStub{ + values: map[string]string{ + "imap-password/work": "secret-work", + }, + } + output := &bytes.Buffer{} + + app := NewAppWithDependencies( + nil, + cfgStore, + func() (secretStore, error) { return secrets, nil }, + nil, + nil, + nil, + nil, + nil, + output, + &bytes.Buffer{}, + "dev", + ) + + if err := app.Run([]string{"config", "delete", "--profile", "work"}); err != nil { + t.Fatalf("config delete returned error: %v", err) + } + + if !secrets.delCalled { + t.Fatal("expected password secret to be deleted") + } + if secrets.delName != "imap-password/work" { + t.Fatalf("deleted secret = %q, want %q", secrets.delName, "imap-password/work") + } + if _, ok := cfgStore.saved.Profiles["work"]; ok { + t.Fatalf("profile work should have been removed, got %#v", cfgStore.saved.Profiles) + } + if cfgStore.saved.CurrentProfile != "default" { + t.Fatalf("current profile = %q, want default", cfgStore.saved.CurrentProfile) + } + + text := output.String() + for _, needle := range []string{`profile "work" deleted`, "current profile: default"} { + if !strings.Contains(text, needle) { + t.Fatalf("output = %q, want substring %q", text, needle) + } + } +} + func TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) { prompter := &capturingPrompterStub{ credential: secretstore.Credential{ diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index e5d21e9..c176664 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "path/filepath" "strings" "time" @@ -89,7 +88,7 @@ func (a *App) doctorProfileCheck(profileFlag string) frameworkcli.DoctorCheck { } } - profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile) + profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile) resolution, err := resolveCredentialFields(cfg.Profiles[profileName], nil, profileFieldSpecs()) if err != nil { var missingErr *frameworkcli.MissingRequiredValuesError @@ -220,15 +219,14 @@ func (a *App) doctorConnectivityCheck(profileFlag string) frameworkcli.DoctorChe } func (a *App) resolveDoctorProfileName(profileFlag string) string { - envProfile := os.Getenv(defaultProfileEnv) if a.configStore == nil { - return frameworkcli.ResolveProfileName(profileFlag, envProfile, "") + return a.resolveProfileName(profileFlag, "") } cfg, _, err := a.configStore.LoadDefault() if err != nil { - return frameworkcli.ResolveProfileName(profileFlag, envProfile, "") + return a.resolveProfileName(profileFlag, "") } - return frameworkcli.ResolveProfileName(profileFlag, envProfile, cfg.CurrentProfile) + return a.resolveProfileName(profileFlag, cfg.CurrentProfile) } diff --git a/internal/cli/wire.go b/internal/cli/wire.go index 400996a..3bfc388 100644 --- a/internal/cli/wire.go +++ b/internal/cli/wire.go @@ -2,8 +2,11 @@ package cli import ( "context" + "fmt" "io" "os" + "path/filepath" + "strings" frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" @@ -57,11 +60,22 @@ func (f runtimeFactories) withDefaults() runtimeFactories { return frameworkconfig.NewStore[ProfileConfig]("email-mcp") } } + if f.loadManifest == nil { + f.loadManifest = frameworkmanifest.LoadDefault + } + if f.resolveExecutable == nil { + f.resolveExecutable = os.Executable + } if f.openSecretStore == nil { f.openSecretStore = func() (secretStore, error) { + policy, err := resolveSecretStorePolicy(f.loadManifest, f.resolveExecutable) + if err != nil { + return nil, err + } + return frameworksecretstore.Open(frameworksecretstore.Options{ ServiceName: "email-mcp", - BackendPolicy: frameworksecretstore.BackendAuto, + BackendPolicy: policy, }) } } @@ -75,12 +89,6 @@ func (f runtimeFactories) withDefaults() runtimeFactories { return mcpserver.NewRunner(mcpserver.New(staticCredentialStore{credential: cred}, mail), input, output, errOut) } } - if f.loadManifest == nil { - f.loadManifest = frameworkmanifest.LoadDefault - } - if f.resolveExecutable == nil { - f.resolveExecutable = os.Executable - } return f } @@ -96,3 +104,52 @@ func (s staticCredentialStore) Save(_ context.Context, _ string, _ secretstore.C func (s staticCredentialStore) Load(_ context.Context, _ string) (secretstore.Credential, error) { return s.credential, nil } + +func resolveSecretStorePolicy(loadManifest manifestLoader, resolveExecutable executableResolver) (frameworksecretstore.BackendPolicy, error) { + if loadManifest == nil { + return frameworksecretstore.BackendAuto, nil + } + + searchDirs := []string{"."} + if resolveExecutable != nil { + executablePath, err := resolveExecutable() + if err == nil { + searchDirs = append([]string{filepath.Dir(executablePath)}, searchDirs...) + } + } + + seen := map[string]struct{}{} + for _, dir := range searchDirs { + trimmedDir := strings.TrimSpace(dir) + if trimmedDir == "" { + continue + } + if _, ok := seen[trimmedDir]; ok { + continue + } + seen[trimmedDir] = struct{}{} + + file, _, err := loadManifest(trimmedDir) + if err != nil { + continue + } + return parseSecretBackendPolicy(file.SecretStore.BackendPolicy) + } + + return frameworksecretstore.BackendAuto, nil +} + +func parseSecretBackendPolicy(raw string) (frameworksecretstore.BackendPolicy, error) { + switch strings.TrimSpace(raw) { + case "", string(frameworksecretstore.BackendAuto): + return frameworksecretstore.BackendAuto, nil + case string(frameworksecretstore.BackendKWalletOnly): + return frameworksecretstore.BackendKWalletOnly, nil + case string(frameworksecretstore.BackendKeyringAny): + return frameworksecretstore.BackendKeyringAny, nil + case string(frameworksecretstore.BackendEnvOnly): + return frameworksecretstore.BackendEnvOnly, nil + default: + return "", fmt.Errorf("invalid secret backend policy %q", strings.TrimSpace(raw)) + } +} diff --git a/internal/cli/wire_test.go b/internal/cli/wire_test.go index 523f845..a59e1b5 100644 --- a/internal/cli/wire_test.go +++ b/internal/cli/wire_test.go @@ -1,6 +1,12 @@ package cli -import "testing" +import ( + "fmt" + "testing" + + frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" + frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" +) func TestBuildAppReturnsConfiguredApp(t *testing.T) { app := BuildApp("dev") @@ -26,3 +32,53 @@ func TestBuildAppReturnsConfiguredApp(t *testing.T) { t.Fatal("expected manifest loader to be configured") } } + +func TestResolveSecretStorePolicyUsesManifestValue(t *testing.T) { + policy, err := resolveSecretStorePolicy( + func(string) (frameworkmanifest.File, string, error) { + return frameworkmanifest.File{ + SecretStore: frameworkmanifest.SecretStore{ + BackendPolicy: "env-only", + }, + }, "/tmp/mcp.toml", nil + }, + func() (string, error) { return "/tmp/bin/email-mcp", nil }, + ) + if err != nil { + t.Fatalf("resolveSecretStorePolicy returned error: %v", err) + } + if policy != frameworksecretstore.BackendEnvOnly { + t.Fatalf("policy = %q, want %q", policy, frameworksecretstore.BackendEnvOnly) + } +} + +func TestResolveSecretStorePolicyReturnsErrorOnInvalidManifestValue(t *testing.T) { + _, err := resolveSecretStorePolicy( + func(string) (frameworkmanifest.File, string, error) { + return frameworkmanifest.File{ + SecretStore: frameworkmanifest.SecretStore{ + BackendPolicy: "invalid-policy", + }, + }, "/tmp/mcp.toml", nil + }, + func() (string, error) { return "/tmp/bin/email-mcp", nil }, + ) + if err == nil { + t.Fatal("expected invalid secret store policy error") + } +} + +func TestResolveSecretStorePolicyFallsBackToAutoWhenManifestMissing(t *testing.T) { + policy, err := resolveSecretStorePolicy( + func(string) (frameworkmanifest.File, string, error) { + return frameworkmanifest.File{}, "", fmt.Errorf("manifest missing") + }, + func() (string, error) { return "/tmp/bin/email-mcp", nil }, + ) + if err != nil { + t.Fatalf("resolveSecretStorePolicy returned error: %v", err) + } + if policy != frameworksecretstore.BackendAuto { + t.Fatalf("policy = %q, want %q", policy, frameworksecretstore.BackendAuto) + } +} diff --git a/mcp.toml b/mcp.toml index b19ea25..54f7c7d 100644 --- a/mcp.toml +++ b/mcp.toml @@ -1,4 +1,20 @@ +binary_name = "email-mcp" +docs_url = "https://gitea.lclr.dev/AI/email-mcp" + [update] source_name = "email-mcp releases" base_url = "https://gitea.lclr.dev" latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/latest" + +[environment] +known = ["EMAIL_MCP_PROFILE", "EMAIL_MCP_HOST", "EMAIL_MCP_USERNAME", "EMAIL_MCP_PASSWORD"] + +[secret_store] +backend_policy = "auto" + +[profiles] +default = "default" +known = ["default"] + +[bootstrap] +description = "Local MCP server to read an IMAP mailbox."