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 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 ### Lancer le serveur MCP
```sh ```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. 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 ## Installation
### Claude Code CLI ### Claude Code CLI

View file

@ -292,8 +292,23 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro
return err return err
} }
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil { if shouldPersistPassword(hasStoredPassword, storedPassword, cred.Password) {
return mapAppError(err) 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 { if cfg.Profiles == nil {
@ -394,8 +409,16 @@ func (a *App) runConfigDelete(_ context.Context, args []string) error {
if err != nil { if err != nil {
return mapAppError(err) return mapAppError(err)
} }
if err := secrets.DeleteSecret(passwordSecretName(profileName)); err != nil && !errors.Is(err, frameworksecretstore.ErrNotFound) { if err := secrets.DeleteSecret(passwordSecretName(profileName)); err != nil {
return mapAppError(err) 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 { if cfg.Profiles != nil {
@ -680,6 +703,13 @@ func passwordSecretName(profileName string) string {
return "imap-password/" + strings.TrimSpace(profileName) 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) { func parseProfileArgs(command string, args []string) (string, error) {
flagSet := flag.NewFlagSet(command, flag.ContinueOnError) flagSet := flag.NewFlagSet(command, flag.ContinueOnError)
flagSet.SetOutput(io.Discard) 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) { func TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) {
prompter := &capturingPrompterStub{ prompter := &capturingPrompterStub{
credential: secretstore.Credential{ 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) { func TestAppRunConfigShowPrintsResolvedConfiguration(t *testing.T) {
cfgStore := &configStoreStub{ cfgStore := &configStoreStub{
cfg: frameworkconfig.FileConfig[ProfileConfig]{ cfg: frameworkconfig.FileConfig[ProfileConfig]{
@ -781,7 +910,9 @@ func TestAppRunDoctorRendersReportAndChecksConnectivity(t *testing.T) {
manifestDir := t.TempDir() manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update] [update]
latest_release_url = "https://example.com/releases/latest" driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev"
`), 0o600); err != nil { `), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err) t.Fatalf("WriteFile returned error: %v", err)
} }
@ -856,7 +987,9 @@ func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) {
manifestDir := t.TempDir() manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update] [update]
latest_release_url = "https://example.com/releases/latest" driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev"
`), 0o600); err != nil { `), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err) t.Fatalf("WriteFile returned error: %v", err)
} }
@ -915,7 +1048,9 @@ func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) {
manifestDir := t.TempDir() manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update] [update]
latest_release_url = "https://example.com/releases/latest" driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev"
`), 0o600); err != nil { `), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err) 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) { func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev") app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev")

View file

@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"time" "time"
@ -12,6 +13,7 @@ import (
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" 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 { 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") return fmt.Errorf("mail service is not configured")
} }
metadata := a.runtimeMetadata()
report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{ report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{
ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](binaryName)), ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](binaryName)),
SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.frameworkSecretStoreFactory()), SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.frameworkSecretStoreFactory()),
ManifestDir: a.doctorManifestDir(), ManifestDir: a.doctorManifestDir(),
ManifestValidator: func(file frameworkmanifest.File, _ string) []string { ManifestValidator: func(file frameworkmanifest.File, _ string) []string {
if strings.TrimSpace(file.Update.LatestReleaseURL) == "" { return validateManifestUpdate(file, metadata.BinaryName)
return []string{"update.latest_release_url must not be empty"}
}
return nil
}, },
ConnectivityCheck: a.doctorConnectivityCheck(profileFlag), ConnectivityCheck: a.doctorConnectivityCheck(profileFlag),
ExtraChecks: []frameworkcli.DoctorCheck{ ExtraChecks: []frameworkcli.DoctorCheck{
@ -230,3 +230,26 @@ func (a *App) resolveDoctorProfileName(profileFlag string) string {
return a.resolveProfileName(profileFlag, cfg.CurrentProfile) 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{ return frameworksecretstore.Open(frameworksecretstore.Options{
ServiceName: "email-mcp", ServiceName: "email-mcp",
BackendPolicy: policy, 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) 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")
}
}