feat: align cli behaviors with mcp-framework rc3

This commit is contained in:
thibaud-leclere 2026-04-14 15:54:17 +02:00
parent b32b6c8a55
commit 8c88084181
6 changed files with 309 additions and 11 deletions

View file

@ -70,6 +70,8 @@ Le binaire demande ensuite :
Si un mot de passe existe déjà dans le wallet, laisser le champ vide le conserve.
Si le backend de secrets est en lecture seule (`[secret_store].backend_policy = "env-only"`), `setup` ne peut pas persister le mot de passe dans un wallet. Dans ce cas, exporte `EMAIL_MCP_PASSWORD` avant `setup`. La commande sauvegarde alors `host`/`username` et utilise le mot de passe depuis lenvironnement.
### Lancer le serveur MCP
```sh
@ -144,6 +146,11 @@ token_env_names = ["GITEA_TOKEN"]
La commande retourne un code de sortie non nul si au moins un check échoue.
Pour lupdate, la validation du manifeste accepte :
- soit `update.latest_release_url`
- soit un couple driver/référentiel (`update.driver`, `update.repository`) avec les champs requis (ex. `update.base_url` pour Gitea)
## Installation
### Claude Code CLI

View file

@ -292,8 +292,23 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro
return err
}
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil {
return mapAppError(err)
if shouldPersistPassword(hasStoredPassword, storedPassword, cred.Password) {
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil {
switch {
case errors.Is(err, frameworksecretstore.ErrReadOnly):
if strings.TrimSpace(os.Getenv(passwordEnv)) == "" {
return newUserFacingError(
fmt.Sprintf("secret backend is read-only; set %s and rerun `email-mcp setup`", passwordEnv),
err,
)
}
if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; password is provided via %s\n", passwordEnv); writeErr != nil {
return writeErr
}
default:
return mapAppError(err)
}
}
}
if cfg.Profiles == nil {
@ -394,8 +409,16 @@ func (a *App) runConfigDelete(_ context.Context, args []string) error {
if err != nil {
return mapAppError(err)
}
if err := secrets.DeleteSecret(passwordSecretName(profileName)); err != nil && !errors.Is(err, frameworksecretstore.ErrNotFound) {
return mapAppError(err)
if err := secrets.DeleteSecret(passwordSecretName(profileName)); err != nil {
switch {
case errors.Is(err, frameworksecretstore.ErrNotFound):
case errors.Is(err, frameworksecretstore.ErrReadOnly):
if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; %s cannot be deleted automatically\n", passwordEnv); writeErr != nil {
return writeErr
}
default:
return mapAppError(err)
}
}
if cfg.Profiles != nil {
@ -680,6 +703,13 @@ func passwordSecretName(profileName string) string {
return "imap-password/" + strings.TrimSpace(profileName)
}
func shouldPersistPassword(hasStoredPassword bool, storedPassword, newPassword string) bool {
if !hasStoredPassword {
return true
}
return storedPassword != newPassword
}
func parseProfileArgs(command string, args []string) (string, error) {
flagSet := flag.NewFlagSet(command, flag.ContinueOnError)
flagSet.SetOutput(io.Discard)

View file

@ -427,6 +427,56 @@ func TestAppRunConfigDeleteRemovesProfileAndSecret(t *testing.T) {
}
}
func TestAppRunConfigDeleteIgnoresReadOnlySecretBackend(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",
},
},
},
}
secrets := &secretStoreStub{
deleteErr: frameworksecretstore.ErrReadOnly,
}
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 _, ok := cfgStore.saved.Profiles["work"]; ok {
t.Fatalf("profile work should have been removed, got %#v", cfgStore.saved.Profiles)
}
text := output.String()
for _, needle := range []string{
"secret backend is read-only; EMAIL_MCP_PASSWORD cannot be deleted automatically",
`profile "work" deleted`,
} {
if !strings.Contains(text, needle) {
t.Fatalf("output = %q, want substring %q", text, needle)
}
}
}
func TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) {
prompter := &capturingPrompterStub{
credential: secretstore.Credential{
@ -478,6 +528,85 @@ func TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) {
}
}
func TestAppRunSetupAllowsReadOnlySecretBackendWhenPasswordEnvIsSet(t *testing.T) {
t.Setenv(passwordEnv, "env-secret")
prompter := &configPrompterStub{
credential: secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "new-secret",
},
}
cfgStore := &configStoreStub{}
secrets := &secretStoreStub{setErr: frameworksecretstore.ErrReadOnly}
output := &bytes.Buffer{}
app := NewAppWithDependencies(
prompter,
cfgStore,
func() (secretStore, error) { return secrets, nil },
nil,
nil,
nil,
nil,
nil,
output,
&bytes.Buffer{},
"dev",
)
if err := app.Run([]string{"setup"}); err != nil {
t.Fatalf("setup returned error: %v", err)
}
if !secrets.setCalled {
t.Fatal("expected password write attempt")
}
if !cfgStore.saveCalled {
t.Fatal("expected config to be saved")
}
if !strings.Contains(output.String(), "secret backend is read-only; password is provided via EMAIL_MCP_PASSWORD") {
t.Fatalf("unexpected output: %q", output.String())
}
}
func TestAppRunSetupFailsOnReadOnlySecretBackendWithoutPasswordEnv(t *testing.T) {
prompter := &configPrompterStub{
credential: secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "new-secret",
},
}
cfgStore := &configStoreStub{}
secrets := &secretStoreStub{setErr: frameworksecretstore.ErrReadOnly}
app := NewAppWithDependencies(
prompter,
cfgStore,
func() (secretStore, error) { return secrets, nil },
nil,
nil,
nil,
nil,
nil,
io.Discard,
&bytes.Buffer{},
"dev",
)
err := app.Run([]string{"setup"})
if err == nil {
t.Fatal("expected setup to fail")
}
if !strings.Contains(err.Error(), "secret backend is read-only; set EMAIL_MCP_PASSWORD and rerun `email-mcp setup`") {
t.Fatalf("unexpected error: %v", err)
}
if cfgStore.saveCalled {
t.Fatal("config must not be saved when password cannot be persisted")
}
}
func TestAppRunConfigShowPrintsResolvedConfiguration(t *testing.T) {
cfgStore := &configStoreStub{
cfg: frameworkconfig.FileConfig[ProfileConfig]{
@ -781,7 +910,9 @@ func TestAppRunDoctorRendersReportAndChecksConnectivity(t *testing.T) {
manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update]
latest_release_url = "https://example.com/releases/latest"
driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev"
`), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
@ -856,7 +987,9 @@ func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) {
manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update]
latest_release_url = "https://example.com/releases/latest"
driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev"
`), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
@ -915,7 +1048,9 @@ func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) {
manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update]
latest_release_url = "https://example.com/releases/latest"
driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev"
`), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
@ -943,6 +1078,74 @@ latest_release_url = "https://example.com/releases/latest"
}
}
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

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"path/filepath"
"runtime"
"strings"
"time"
@ -12,6 +13,7 @@ import (
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"
)
func (a *App) runDoctor(ctx context.Context, args []string) error {
@ -29,15 +31,13 @@ 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.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
return validateManifestUpdate(file, metadata.BinaryName)
},
ConnectivityCheck: a.doctorConnectivityCheck(profileFlag),
ExtraChecks: []frameworkcli.DoctorCheck{
@ -230,3 +230,26 @@ 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

@ -76,6 +76,13 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
return frameworksecretstore.Open(frameworksecretstore.Options{
ServiceName: "email-mcp",
BackendPolicy: policy,
LookupEnv: func(name string) (string, bool) {
trimmedName := strings.TrimSpace(name)
if strings.HasPrefix(trimmedName, "imap-password/") {
return os.LookupEnv(passwordEnv)
}
return os.LookupEnv(trimmedName)
},
})
}
}

View file

@ -82,3 +82,31 @@ func TestResolveSecretStorePolicyFallsBackToAutoWhenManifestMissing(t *testing.T
t.Fatalf("policy = %q, want %q", policy, frameworksecretstore.BackendAuto)
}
}
func TestBuildAppOpenSecretStoreMapsProfilePasswordToEnvironment(t *testing.T) {
t.Setenv(passwordEnv, "env-secret")
app := buildApp(nil, nil, nil, "dev", runtimeFactories{
loadManifest: func(string) (frameworkmanifest.File, string, error) {
return frameworkmanifest.File{
SecretStore: frameworkmanifest.SecretStore{
BackendPolicy: "env-only",
},
}, "/tmp/mcp.toml", nil
},
resolveExecutable: func() (string, error) { return "/tmp/bin/email-mcp", nil },
})
store, err := app.openSecretStore()
if err != nil {
t.Fatalf("openSecretStore returned error: %v", err)
}
value, err := store.GetSecret("imap-password/work")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "env-secret" {
t.Fatalf("GetSecret = %q, want %q", value, "env-secret")
}
}