feat(cli): adopt framework v1.3 manifest metadata and config delete
This commit is contained in:
parent
88818641e4
commit
7998e049cb
7 changed files with 470 additions and 25 deletions
|
|
@ -15,6 +15,7 @@ Le binaire s’appuie 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 l’accè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 l’endpoint Gitea :
|
||||
|
||||
```toml
|
||||
binary_name = "email-mcp"
|
||||
|
||||
[update]
|
||||
source_name = "email-mcp releases"
|
||||
base_url = "https://gitea.lclr.dev"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
mcp.toml
16
mcp.toml
|
|
@ -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."
|
||||
|
|
|
|||
Loading…
Reference in a new issue