refactor(cli): upgrade mcp-framework to v1.3.1 and remove glue code

This commit is contained in:
thibaud-lclr 2026-04-14 17:09:46 +02:00
parent fcee5a0a36
commit ba42be8d77
7 changed files with 70 additions and 183 deletions

2
go.mod
View file

@ -3,7 +3,7 @@ module email-mcp
go 1.25.0 go 1.25.0
require ( require (
gitea.lclr.dev/AI/mcp-framework v1.3.0-rc3 gitea.lclr.dev/AI/mcp-framework v1.3.1
github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2 github.com/emersion/go-message v0.18.2
github.com/godbus/dbus/v5 v5.2.2 github.com/godbus/dbus/v5 v5.2.2

4
go.sum
View file

@ -1,5 +1,5 @@
gitea.lclr.dev/AI/mcp-framework v1.3.0-rc3 h1:r++aJfZFsp+Bi9fUyqb/KjblciV6I3SdvHT3QbpmPZg= gitea.lclr.dev/AI/mcp-framework v1.3.1 h1:GxT5bV22+hbLLUz2IMCscb3qLdkqX5u9d1dbHnUs7RI=
gitea.lclr.dev/AI/mcp-framework v1.3.0-rc3/go.mod h1:kUVMrL3/UBYgjOsW7sJCs3V0pO0qoJJMpIpueoTsoA4= gitea.lclr.dev/AI/mcp-framework v1.3.1/go.mod h1:kUVMrL3/UBYgjOsW7sJCs3V0pO0qoJJMpIpueoTsoA4=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=

View file

@ -538,31 +538,11 @@ func resolveCredentialFields(profile ProfileConfig, store secretStore, fields []
return frameworkcli.ResolveFields(frameworkcli.ResolveOptions{ return frameworkcli.ResolveFields(frameworkcli.ResolveOptions{
Fields: fields, Fields: fields,
Lookup: func(source frameworkcli.ValueSource, key string) (string, bool, error) { Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{
switch source { Env: frameworkcli.EnvLookup(os.LookupEnv),
case frameworkcli.SourceEnv: Config: frameworkcli.ConfigMap(configValues),
value, ok := os.LookupEnv(strings.TrimSpace(key)) Secret: frameworkcli.SecretStore(store),
return value, ok, nil }),
case frameworkcli.SourceConfig:
value, ok := configValues[strings.TrimSpace(key)]
return value, ok, nil
case frameworkcli.SourceSecret:
if store == nil {
return "", false, nil
}
value, err := store.GetSecret(strings.TrimSpace(key))
if err != nil {
if errors.Is(err, frameworksecretstore.ErrNotFound) {
return "", false, nil
}
return "", false, err
}
return value, true, nil
default:
return "", false, nil
}
},
}) })
} }

View file

@ -963,7 +963,7 @@ base_url = "https://gitea.lclr.dev"
text := output.String() text := output.String()
for _, needle := range []string{ for _, needle := range []string{
"[OK] config: config file is readable", "[OK] config: config file is readable",
"[OK] profile: resolved profile is complete", "[OK] profile: required profile values are resolved",
"[OK] password: stored password is present", "[OK] password: stored password is present",
"[OK] connectivity: IMAP server is reachable", "[OK] connectivity: IMAP server is reachable",
"Summary: 6 ok, 0 warning(s), 0 failure(s), 6 total", "Summary: 6 ok, 0 warning(s), 0 failure(s), 6 total",

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
@ -40,7 +41,7 @@ func (a *App) runDoctor(ctx context.Context, args []string) error {
}, },
ConnectivityCheck: a.doctorConnectivityCheck(profileFlag), ConnectivityCheck: a.doctorConnectivityCheck(profileFlag),
ExtraChecks: []frameworkcli.DoctorCheck{ ExtraChecks: []frameworkcli.DoctorCheck{
a.doctorProfileCheck(profileFlag), a.doctorRequiredProfileFieldsCheck(profileFlag),
a.doctorPasswordCheck(profileFlag), a.doctorPasswordCheck(profileFlag),
}, },
}) })
@ -65,53 +66,44 @@ func (a *App) doctorManifestDir() string {
return filepath.Dir(executablePath) return filepath.Dir(executablePath)
} }
func (a *App) doctorProfileCheck(profileFlag string) frameworkcli.DoctorCheck { func (a *App) doctorRequiredProfileFieldsCheck(profileFlag string) frameworkcli.DoctorCheck {
return func(context.Context) frameworkcli.DoctorResult { var (
cfg, _, err := a.configStore.LoadDefault() profileValues map[string]string
if err != nil { loadErr error
return frameworkcli.DoctorResult{ )
Name: "profile",
Status: frameworkcli.DoctorStatusFail,
Summary: "cannot load profile configuration",
Detail: err.Error(),
}
}
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile) check := frameworkcli.RequiredResolvedFieldsCheck(frameworkcli.ResolveOptions{
resolution, err := resolveCredentialFields(cfg.Profiles[profileName], nil, profileFieldSpecs()) Fields: profileFieldSpecs(),
if err != nil { Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{
var missingErr *frameworkcli.MissingRequiredValuesError Env: frameworkcli.EnvLookup(os.LookupEnv),
if errors.As(err, &missingErr) { Config: func(key string) (string, bool, error) {
return frameworkcli.DoctorResult{ if loadErr != nil {
Name: "profile", return "", false, loadErr
Status: frameworkcli.DoctorStatusFail,
Summary: "resolved profile is incomplete",
Detail: fmt.Sprintf("profile %q: missing %s", profileName, strings.Join(missingErr.Fields, ", ")),
} }
} if profileValues == nil {
cfg, _, err := a.configStore.LoadDefault()
if err != nil {
loadErr = err
return "", false, loadErr
}
return frameworkcli.DoctorResult{ profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
Name: "profile", profile := cfg.Profiles[profileName]
Status: frameworkcli.DoctorStatusFail, profileValues = map[string]string{
Summary: "cannot resolve profile values", "host": profile.Host,
Detail: err.Error(), "username": profile.Username,
} }
} }
host, _ := resolution.Get("host") return frameworkcli.MapLookup(profileValues)(key)
username, _ := resolution.Get("username") },
}),
})
return frameworkcli.DoctorResult{ return func(ctx context.Context) frameworkcli.DoctorResult {
Name: "profile", result := check(ctx)
Status: frameworkcli.DoctorStatusOK, result.Name = "profile"
Summary: "resolved profile is complete", return result
Detail: fmt.Sprintf(
"profile %q (host: %s, username: %s)",
profileName,
host.Source,
username.Source,
),
}
} }
} }

View file

@ -2,10 +2,8 @@ package cli
import ( import (
"context" "context"
"fmt"
"io" "io"
"os" "os"
"path/filepath"
"strings" "strings"
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
@ -68,14 +66,10 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
} }
if f.openSecretStore == nil { if f.openSecretStore == nil {
f.openSecretStore = func() (secretStore, error) { f.openSecretStore = func() (secretStore, error) {
policy, err := resolveSecretStorePolicy(f.loadManifest, f.resolveExecutable) return frameworksecretstore.OpenFromManifest(frameworksecretstore.OpenFromManifestOptions{
if err != nil { ServiceName: "email-mcp",
return nil, err ManifestLoader: frameworksecretstore.ManifestLoader(f.loadManifest),
} ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable),
return frameworksecretstore.Open(frameworksecretstore.Options{
ServiceName: "email-mcp",
BackendPolicy: policy,
LookupEnv: func(name string) (string, bool) { LookupEnv: func(name string) (string, bool) {
trimmedName := strings.TrimSpace(name) trimmedName := strings.TrimSpace(name)
if strings.HasPrefix(trimmedName, "imap-password/") { if strings.HasPrefix(trimmedName, "imap-password/") {
@ -111,52 +105,3 @@ func (s staticCredentialStore) Save(_ context.Context, _ string, _ secretstore.C
func (s staticCredentialStore) Load(_ context.Context, _ string) (secretstore.Credential, error) { func (s staticCredentialStore) Load(_ context.Context, _ string) (secretstore.Credential, error) {
return s.credential, nil 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,11 +1,10 @@
package cli package cli
import ( import (
"fmt" "strings"
"testing" "testing"
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
) )
func TestBuildAppReturnsConfiguredApp(t *testing.T) { func TestBuildAppReturnsConfiguredApp(t *testing.T) {
@ -33,56 +32,6 @@ func TestBuildAppReturnsConfiguredApp(t *testing.T) {
} }
} }
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)
}
}
func TestBuildAppOpenSecretStoreMapsProfilePasswordToEnvironment(t *testing.T) { func TestBuildAppOpenSecretStoreMapsProfilePasswordToEnvironment(t *testing.T) {
t.Setenv(passwordEnv, "env-secret") t.Setenv(passwordEnv, "env-secret")
@ -110,3 +59,24 @@ func TestBuildAppOpenSecretStoreMapsProfilePasswordToEnvironment(t *testing.T) {
t.Fatalf("GetSecret = %q, want %q", value, "env-secret") t.Fatalf("GetSecret = %q, want %q", value, "env-secret")
} }
} }
func TestBuildAppOpenSecretStoreReturnsErrorOnInvalidManifestPolicy(t *testing.T) {
app := buildApp(nil, nil, nil, "dev", runtimeFactories{
loadManifest: func(string) (frameworkmanifest.File, string, error) {
return frameworkmanifest.File{
SecretStore: frameworkmanifest.SecretStore{
BackendPolicy: "invalid-policy",
},
}, "/tmp/mcp.toml", nil
},
resolveExecutable: func() (string, error) { return "/tmp/bin/email-mcp", nil },
})
_, err := app.openSecretStore()
if err == nil {
t.Fatal("expected invalid secret store policy error")
}
if !strings.Contains(err.Error(), "invalid secret_store.backend_policy") {
t.Fatalf("unexpected error: %v", err)
}
}