feat(cli): adopt framework v1.3 manifest metadata and config delete

This commit is contained in:
thibaud-leclere 2026-04-14 12:48:36 +02:00
parent 88818641e4
commit 7998e049cb
7 changed files with 470 additions and 25 deletions

View file

@ -15,6 +15,7 @@ Le binaire sappuie 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 laccè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 lendpoint Gitea :
```toml
binary_name = "email-mcp"
[update]
source_name = "email-mcp releases"
base_url = "https://gitea.lclr.dev"

View file

@ -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 <command> [args]\n\n", binaryName); err != nil {
if _, err := fmt.Fprintf(a.stdout, "Usage:\n %s <command> [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 <command>\n", binaryName)
_, err := fmt.Fprintf(a.stdout, "\nDetailed help: %s help <command>\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

View file

@ -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 <command>", "email-mcp-custom help <command>"} {
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{

View file

@ -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)
}

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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."