feat: wire email mcp application graph

This commit is contained in:
thibaud-leclere 2026-04-10 12:21:58 +02:00
parent 2c1dab1bb2
commit e09b4afa0a
4 changed files with 208 additions and 52 deletions

View file

@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"io"
"os"
"email-mcp/internal/mcpserver"
"email-mcp/internal/secretstore"
@ -24,7 +23,7 @@ type App struct {
}
func NewApp() *App {
return NewAppWithDependencies(nil, nil, nil, os.Stderr)
return BuildApp()
}
func NewAppWithDependencies(prompter SetupPrompter, store secretstore.Store, runner MCPRunner, stderr io.Writer) *App {

View file

@ -55,7 +55,7 @@ func (r *runnerStub) Run(context.Context) error {
}
func TestAppRunRejectsUnknownCommand(t *testing.T) {
app := NewApp()
app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{})
err := app.Run([]string{"unknown"})
if err == nil {
@ -64,7 +64,7 @@ func TestAppRunRejectsUnknownCommand(t *testing.T) {
}
func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) {
app := NewApp()
app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{})
err := app.Run(nil)
if err == nil {
@ -148,7 +148,7 @@ func TestAppRunMCPDelegatesToRunner(t *testing.T) {
}
func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
app := NewApp()
app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{})
tests := []struct {
command string
@ -170,3 +170,19 @@ func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
})
}
}
func TestNewAppBuildsProductionDependencies(t *testing.T) {
app := NewApp()
if app == nil {
t.Fatal("expected app instance")
}
if app.prompter == nil {
t.Fatal("expected setup prompter to be configured")
}
if app.store == nil {
t.Fatal("expected secret store to be configured")
}
if app.runner == nil {
t.Fatal("expected MCP runner to be configured")
}
}

View file

@ -1,44 +1,62 @@
package cli
import (
"context"
"errors"
"io"
"os"
"email-mcp/internal/imapclient"
"email-mcp/internal/mcpserver"
"email-mcp/internal/secretstore"
"email-mcp/internal/secretstore/kwallet"
)
var errSecretStoreNotConfigured = errors.New("secret store is not available in this build yet")
var errMCPRunnerNotConfigured = errors.New("mcp runner is not available in this build yet")
type placeholderStore struct{}
func (placeholderStore) Save(context.Context, string, secretstore.Credential) error {
return errSecretStoreNotConfigured
}
func (placeholderStore) Load(context.Context, string) (secretstore.Credential, error) {
return secretstore.Credential{}, errSecretStoreNotConfigured
}
type placeholderRunner struct{}
func (placeholderRunner) Run(context.Context) error {
return errMCPRunnerNotConfigured
type runtimeFactories struct {
newPrompter func(io.Reader, io.Writer) SetupPrompter
newWalletClient func() kwallet.Client
newStore func(kwallet.Client) secretstore.Store
newMailService func() mcpserver.MailService
newRunner func(secretstore.Store, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
}
func BuildApp() *App {
return buildApp(os.Stdin, os.Stdout, os.Stderr)
return buildApp(os.Stdin, os.Stdout, os.Stderr, runtimeFactories{})
}
func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer) *App {
_ = stdout
func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, factories runtimeFactories) *App {
factories = factories.withDefaults()
return NewAppWithDependencies(
NewInteractiveSetupPrompter(stdin, stderr),
placeholderStore{},
placeholderRunner{},
stderr,
)
prompter := factories.newPrompter(stdin, stderr)
store := factories.newStore(factories.newWalletClient())
mailService := factories.newMailService()
runner := factories.newRunner(store, mailService, stdin, stdout, stderr)
return NewAppWithDependencies(prompter, store, runner, stderr)
}
func (f runtimeFactories) withDefaults() runtimeFactories {
if f.newPrompter == nil {
f.newPrompter = func(input io.Reader, output io.Writer) SetupPrompter {
return NewInteractiveSetupPrompter(input, output)
}
}
if f.newWalletClient == nil {
f.newWalletClient = kwallet.NewDefaultWalletClient
}
if f.newStore == nil {
f.newStore = func(client kwallet.Client) secretstore.Store {
return kwallet.NewStore(client)
}
}
if f.newMailService == nil {
f.newMailService = func() mcpserver.MailService {
return imapclient.NewService(imapclient.NewDefaultBackend())
}
}
if f.newRunner == nil {
f.newRunner = func(store secretstore.Store, mail mcpserver.MailService, input io.Reader, output io.Writer, errOut io.Writer) MCPRunner {
return mcpserver.NewRunner(mcpserver.New(store, mail), input, output, errOut)
}
}
return f
}

View file

@ -2,36 +2,159 @@ package cli
import (
"bytes"
"context"
"io"
"strings"
"testing"
"email-mcp/internal/imapclient"
"email-mcp/internal/mcpserver"
"email-mcp/internal/secretstore"
"email-mcp/internal/secretstore/kwallet"
)
func TestBuildAppConfiguresSetupCommand(t *testing.T) {
stdin := strings.NewReader("imap.example.com\nalice\nsecret\n")
type walletClientStub struct{}
func (walletClientStub) IsAvailable(context.Context) error {
return nil
}
func (walletClientStub) Open(context.Context) error {
return nil
}
func (walletClientStub) WriteEntry(context.Context, string, []byte) error {
return nil
}
func (walletClientStub) ReadEntry(context.Context, string) ([]byte, error) {
return nil, nil
}
type wireStoreStub struct {
saved secretstore.Credential
savedKey string
saveCalled bool
}
func (s *wireStoreStub) Save(_ context.Context, key string, cred secretstore.Credential) error {
s.saveCalled = true
s.savedKey = key
s.saved = cred
return nil
}
func (s *wireStoreStub) Load(context.Context, string) (secretstore.Credential, error) {
return secretstore.Credential{}, nil
}
type wireMailServiceStub struct{}
func (wireMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
return nil, nil
}
func (wireMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) {
return nil, nil
}
func (wireMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) {
return imapclient.Message{}, nil
}
func TestBuildAppWiresSetupAndMCPCommands(t *testing.T) {
stdin := strings.NewReader("unused")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
app := buildApp(stdin, stdout, stderr)
err := app.Run([]string{"setup"})
if err == nil {
t.Fatal("expected placeholder store error")
prompter := &promptStub{
credential: secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "secret",
},
}
if err.Error() != errSecretStoreNotConfigured.Error() {
t.Fatalf("expected placeholder store error, got %v", err)
store := &wireStoreStub{}
mail := wireMailServiceStub{}
runner := &runnerStub{}
walletClient := &walletClientStub{}
var gotStoreClient kwallet.Client
var gotRunnerStore secretstore.Store
var gotRunnerMail mcpserver.MailService
app := buildApp(stdin, stdout, stderr, runtimeFactories{
newPrompter: func(in io.Reader, errOut io.Writer) SetupPrompter {
if in != stdin {
t.Fatalf("expected stdin to be forwarded to prompter")
}
if errOut != stderr {
t.Fatalf("expected stderr to be forwarded to prompter")
}
return prompter
},
newWalletClient: func() kwallet.Client {
return walletClient
},
newStore: func(client kwallet.Client) secretstore.Store {
gotStoreClient = client
return store
},
newMailService: func() mcpserver.MailService {
return mail
},
newRunner: func(store secretstore.Store, mail mcpserver.MailService, in io.Reader, out io.Writer, errOut io.Writer) MCPRunner {
gotRunnerStore = store
gotRunnerMail = mail
if in != stdin {
t.Fatalf("expected stdin to be forwarded to runner")
}
if out != stdout {
t.Fatalf("expected stdout to be forwarded to runner")
}
if errOut != stderr {
t.Fatalf("expected stderr to be forwarded to runner")
}
return runner
},
})
if err := app.Run([]string{"setup"}); err != nil {
t.Fatalf("setup returned error: %v", err)
}
if got := stderr.String(); got != "IMAP host: Username: Password: " {
t.Fatalf("expected setup prompts on stderr, got %q", got)
if !prompter.called {
t.Fatal("expected setup prompter to be called")
}
if !store.saveCalled {
t.Fatal("expected store Save to be called")
}
if store.savedKey != secretstore.DefaultAccountKey {
t.Fatalf("expected setup to save %q, got %q", secretstore.DefaultAccountKey, store.savedKey)
}
if store.saved.Host != "imap.example.com" || store.saved.Username != "alice" || store.saved.Password != "secret" {
t.Fatalf("unexpected saved credential: %#v", store.saved)
}
if err := app.Run([]string{"mcp"}); err != nil {
t.Fatalf("mcp returned error: %v", err)
}
if !runner.called {
t.Fatal("expected MCP runner to be called")
}
if gotStoreClient != walletClient {
t.Fatal("expected wallet client to be passed to the store factory")
}
if gotRunnerStore != store {
t.Fatal("expected runner to receive the assembled store")
}
if gotRunnerMail != mail {
t.Fatal("expected runner to receive the assembled mail service")
}
}
func TestBuildAppConfiguresMCPCommand(t *testing.T) {
app := buildApp(strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
err := app.Run([]string{"mcp"})
if err == nil {
t.Fatal("expected placeholder runner error")
}
if err.Error() != errMCPRunnerNotConfigured.Error() {
t.Fatalf("expected placeholder runner error, got %v", err)
func TestBuildAppReturnsConfiguredApp(t *testing.T) {
app := BuildApp()
if app == nil {
t.Fatal("expected app instance")
}
}