feat: align cli behaviors with mcp-framework rc3
This commit is contained in:
parent
b32b6c8a55
commit
8c88084181
6 changed files with 309 additions and 11 deletions
|
|
@ -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 l’environnement.
|
||||
|
||||
### 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 l’update, 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
|
||||
|
|
|
|||
|
|
@ -292,9 +292,24 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro
|
|||
return 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 {
|
||||
cfg.Profiles = map[string]ProfileConfig{}
|
||||
|
|
@ -394,9 +409,17 @@ 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) {
|
||||
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 {
|
||||
delete(cfg.Profiles, profileName)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue