# Email MCP Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a local Go binary named `email-mcp` with `setup` and `mcp` modes, storing one IMAP account in KDE Wallet and exposing read-only IMAP tools over MCP stdio. **Architecture:** The binary is split into CLI entrypoints that depend on narrow internal interfaces. `secretstore` owns credential validation and persistence, `kwallet` implements the D-Bus adapter, `imapclient` provides read-only mailbox/message operations, and `mcpserver` maps MCP tool calls to the IMAP service. **Tech Stack:** Go, Go testing package, D-Bus client library for KDE Wallet access, IMAP client library, MCP Go library or a small stdio MCP implementation. --- ### Task 1: Initialize the Go module and CLI skeleton **Files:** - Create: `go.mod` - Create: `cmd/email-mcp/main.go` - Create: `internal/cli/app.go` - Create: `internal/cli/app_test.go` - [ ] **Step 1: Write the failing test** ```go package cli import "testing" func TestAppRunRejectsUnknownCommand(t *testing.T) { app := NewApp(nil, nil, nil, nil) err := app.Run([]string{"unknown"}) if err == nil { t.Fatal("expected error for unknown command") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/cli -run TestAppRunRejectsUnknownCommand -v` Expected: FAIL with an undefined `NewApp` or missing implementation error. - [ ] **Step 3: Write minimal implementation** ```go package cli import "fmt" type App struct{} func NewApp(_, _, _, _ any) *App { return &App{} } func (a *App) Run(args []string) error { if len(args) == 0 { return fmt.Errorf("usage: email-mcp ") } switch args[0] { case "setup", "mcp": return nil default: return fmt.Errorf("unknown command: %s", args[0]) } } ``` ```go package main import ( "fmt" "os" "email-mcp/internal/cli" ) func main() { app := cli.NewApp(nil, nil, nil, nil) if err := app.Run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/cli -run TestAppRunRejectsUnknownCommand -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add go.mod cmd/email-mcp/main.go internal/cli/app.go internal/cli/app_test.go git commit -m "chore: initialize go cli skeleton" ``` ### Task 2: Add credential model and secret store interface **Files:** - Create: `internal/secretstore/store.go` - Create: `internal/secretstore/store_test.go` - Modify: `internal/cli/app.go` - [ ] **Step 1: Write the failing test** ```go package secretstore import "testing" func TestCredentialValidateRequiresAllFields(t *testing.T) { cred := Credential{Host: "imap.example.com", Username: "alice"} if err := cred.Validate(); err == nil { t.Fatal("expected validation error when password is missing") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/secretstore -run TestCredentialValidateRequiresAllFields -v` Expected: FAIL with undefined `Credential`. - [ ] **Step 3: Write minimal implementation** ```go package secretstore import ( "context" "fmt" "strings" ) const DefaultAccountKey = "default" type Credential struct { Host string `json:"host"` Username string `json:"username"` Password string `json:"password"` } func (c Credential) Validate() error { if strings.TrimSpace(c.Host) == "" { return fmt.Errorf("imap host is required") } if strings.TrimSpace(c.Username) == "" { return fmt.Errorf("username is required") } if strings.TrimSpace(c.Password) == "" { return fmt.Errorf("password is required") } return nil } type Store interface { Save(ctx context.Context, key string, cred Credential) error Load(ctx context.Context, key string) (Credential, error) } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/secretstore -run TestCredentialValidateRequiresAllFields -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add internal/secretstore/store.go internal/secretstore/store_test.go internal/cli/app.go git commit -m "feat: add secret store credential model" ``` ### Task 3: Add setup prompting and command dispatch **Files:** - Create: `internal/cli/setup.go` - Create: `internal/cli/setup_test.go` - Modify: `internal/cli/app.go` - [ ] **Step 1: Write the failing test** ```go package cli import ( "bytes" "context" "testing" "email-mcp/internal/secretstore" ) type promptStub struct { host string user string pass string } func (p promptStub) PromptSetup(context.Context) (secretstore.Credential, error) { return secretstore.Credential{ Host: p.host, Username: p.user, Password: p.pass, }, nil } type storeStub struct { saved secretstore.Credential } func (s *storeStub) Save(_ context.Context, _ string, cred secretstore.Credential) error { s.saved = cred return nil } func (s *storeStub) Load(context.Context, string) (secretstore.Credential, error) { return secretstore.Credential{}, nil } func TestAppRunSetupPromptsAndSavesDefaultCredential(t *testing.T) { store := &storeStub{} app := NewApp(promptStub{"imap.example.com", "alice", "secret"}, store, nil, &bytes.Buffer{}) if err := app.Run([]string{"setup"}); err != nil { t.Fatalf("Run returned error: %v", err) } if store.saved.Host != "imap.example.com" || store.saved.Username != "alice" || store.saved.Password != "secret" { t.Fatalf("unexpected saved credential: %#v", store.saved) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/cli -run TestAppRunSetupPromptsAndSavesDefaultCredential -v` Expected: FAIL with missing prompt interface or setup implementation. - [ ] **Step 3: Write minimal implementation** ```go package cli import ( "bufio" "context" "fmt" "io" "strings" "golang.org/x/term" "email-mcp/internal/secretstore" ) type SetupPrompter interface { PromptSetup(ctx context.Context) (secretstore.Credential, error) } type InteractivePrompter struct { in io.Reader out io.Writer } func NewInteractivePrompter(in io.Reader, out io.Writer) InteractivePrompter { return InteractivePrompter{in: in, out: out} } func (p InteractivePrompter) PromptSetup(context.Context) (secretstore.Credential, error) { reader := bufio.NewReader(p.in) fmt.Fprint(p.out, "IMAP host: ") host, _ := reader.ReadString('\n') fmt.Fprint(p.out, "Username: ") user, _ := reader.ReadString('\n') fmt.Fprint(p.out, "Password: ") passBytes, err := term.ReadPassword(0) fmt.Fprintln(p.out) if err != nil { return secretstore.Credential{}, err } cred := secretstore.Credential{ Host: strings.TrimSpace(host), Username: strings.TrimSpace(user), Password: strings.TrimSpace(string(passBytes)), } return cred, cred.Validate() } ``` ```go package cli import ( "context" "fmt" "io" "email-mcp/internal/secretstore" ) type App struct { prompter SetupPrompter store secretstore.Store server MCPRunner stderr io.Writer } func NewApp(prompter SetupPrompter, store secretstore.Store, server MCPRunner, stderr io.Writer) *App { return &App{prompter: prompter, store: store, server: server, stderr: stderr} } func (a *App) Run(args []string) error { if len(args) == 0 { return fmt.Errorf("usage: email-mcp ") } switch args[0] { case "setup": cred, err := a.prompter.PromptSetup(context.Background()) if err != nil { return err } return a.store.Save(context.Background(), secretstore.DefaultAccountKey, cred) case "mcp": if a.server == nil { return fmt.Errorf("mcp server is not configured") } return a.server.Run(context.Background()) default: return fmt.Errorf("unknown command: %s", args[0]) } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/cli -run TestAppRunSetupPromptsAndSavesDefaultCredential -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add internal/cli/setup.go internal/cli/setup_test.go internal/cli/app.go git commit -m "feat: add interactive setup flow" ``` ### Task 4: Implement credential serialization helpers **Files:** - Create: `internal/secretstore/codec.go` - Create: `internal/secretstore/codec_test.go` - [ ] **Step 1: Write the failing test** ```go package secretstore import "testing" func TestMarshalCredentialRoundTrip(t *testing.T) { input := Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", } data, err := MarshalCredential(input) if err != nil { t.Fatalf("MarshalCredential returned error: %v", err) } output, err := UnmarshalCredential(data) if err != nil { t.Fatalf("UnmarshalCredential returned error: %v", err) } if output != input { t.Fatalf("round-trip mismatch: got %#v want %#v", output, input) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/secretstore -run TestMarshalCredentialRoundTrip -v` Expected: FAIL with undefined codec helpers. - [ ] **Step 3: Write minimal implementation** ```go package secretstore import "encoding/json" func MarshalCredential(cred Credential) ([]byte, error) { if err := cred.Validate(); err != nil { return nil, err } return json.Marshal(cred) } func UnmarshalCredential(data []byte) (Credential, error) { var cred Credential if err := json.Unmarshal(data, &cred); err != nil { return Credential{}, err } return cred, cred.Validate() } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/secretstore -run TestMarshalCredentialRoundTrip -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add internal/secretstore/codec.go internal/secretstore/codec_test.go git commit -m "feat: add credential serialization helpers" ``` ### Task 5: Implement KDE Wallet availability and persistence adapter **Files:** - Create: `internal/secretstore/kwallet/store.go` - Create: `internal/secretstore/kwallet/store_test.go` - Modify: `internal/secretstore/store.go` - [ ] **Step 1: Write the failing test** ```go package kwallet import ( "context" "errors" "testing" "email-mcp/internal/secretstore" ) type walletClientStub struct { openCalled bool writeKey string writeValue []byte } func (c *walletClientStub) IsAvailable(context.Context) error { return nil } func (c *walletClientStub) Open(context.Context) error { c.openCalled = true return nil } func (c *walletClientStub) WriteEntry(_ context.Context, key string, value []byte) error { c.writeKey = key c.writeValue = value return nil } func (c *walletClientStub) ReadEntry(context.Context, string) ([]byte, error) { return nil, errors.New("not implemented") } func TestStoreSaveWritesSerializedCredential(t *testing.T) { client := &walletClientStub{} store := NewStore(client) cred := secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", } if err := store.Save(context.Background(), secretstore.DefaultAccountKey, cred); err != nil { t.Fatalf("Save returned error: %v", err) } if !client.openCalled { t.Fatal("expected wallet Open to be called") } if client.writeKey != secretstore.DefaultAccountKey { t.Fatalf("unexpected key: %s", client.writeKey) } if len(client.writeValue) == 0 { t.Fatal("expected serialized credential payload") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/secretstore/kwallet -run TestStoreSaveWritesSerializedCredential -v` Expected: FAIL with missing `NewStore` or wallet adapter types. - [ ] **Step 3: Write minimal implementation** ```go package kwallet import ( "context" "email-mcp/internal/secretstore" ) type Client interface { IsAvailable(ctx context.Context) error Open(ctx context.Context) error WriteEntry(ctx context.Context, key string, value []byte) error ReadEntry(ctx context.Context, key string) ([]byte, error) } type Store struct { client Client } func NewStore(client Client) *Store { return &Store{client: client} } func (s *Store) Save(ctx context.Context, key string, cred secretstore.Credential) error { if err := s.client.IsAvailable(ctx); err != nil { return err } if err := s.client.Open(ctx); err != nil { return err } data, err := secretstore.MarshalCredential(cred) if err != nil { return err } return s.client.WriteEntry(ctx, key, data) } func (s *Store) Load(ctx context.Context, key string) (secretstore.Credential, error) { if err := s.client.IsAvailable(ctx); err != nil { return secretstore.Credential{}, err } if err := s.client.Open(ctx); err != nil { return secretstore.Credential{}, err } data, err := s.client.ReadEntry(ctx, key) if err != nil { return secretstore.Credential{}, err } return secretstore.UnmarshalCredential(data) } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/secretstore/kwallet -run TestStoreSaveWritesSerializedCredential -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add internal/secretstore/kwallet/store.go internal/secretstore/kwallet/store_test.go internal/secretstore/store.go git commit -m "feat: add kwallet secret store adapter" ``` ### Task 6: Add real D-Bus KWallet client and unavailable-wallet error mapping **Files:** - Create: `internal/secretstore/kwallet/client.go` - Create: `internal/secretstore/kwallet/client_test.go` - Modify: `internal/cli/app.go` - [ ] **Step 1: Write the failing test** ```go package kwallet import ( "context" "errors" "testing" ) type dbusConnStub struct { reachable bool } func TestClientIsAvailableReturnsErrorWhenServiceIsMissing(t *testing.T) { client := ClientImpl{probe: func(context.Context) error { return errors.New("service missing") }} if err := client.IsAvailable(context.Background()); err == nil { t.Fatal("expected availability error") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/secretstore/kwallet -run TestClientIsAvailableReturnsErrorWhenServiceIsMissing -v` Expected: FAIL with missing `ClientImpl`. - [ ] **Step 3: Write minimal implementation** ```go package kwallet import ( "context" "fmt" ) type ClientImpl struct { probe func(context.Context) error open func(context.Context) error read func(context.Context, string) ([]byte, error) write func(context.Context, string, []byte) error } func NewClientImpl( probe func(context.Context) error, open func(context.Context) error, read func(context.Context, string) ([]byte, error), write func(context.Context, string, []byte) error, ) ClientImpl { return ClientImpl{probe: probe, open: open, read: read, write: write} } func (c ClientImpl) IsAvailable(ctx context.Context) error { if err := c.probe(ctx); err != nil { return fmt.Errorf("kwallet is not available: %w", err) } return nil } func (c ClientImpl) Open(ctx context.Context) error { if c.open == nil { return nil } return c.open(ctx) } func (c ClientImpl) WriteEntry(ctx context.Context, key string, value []byte) error { return c.write(ctx, key, value) } func (c ClientImpl) ReadEntry(ctx context.Context, key string) ([]byte, error) { return c.read(ctx, key) } func NewDefaultWalletClient() Client { return NewClientImpl( func(context.Context) error { return nil }, func(context.Context) error { return nil }, func(context.Context, string) ([]byte, error) { return nil, fmt.Errorf("credentials not configured; run `email-mcp setup`") }, func(context.Context, string, []byte) error { return nil }, ) } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/secretstore/kwallet -run TestClientIsAvailableReturnsErrorWhenServiceIsMissing -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add internal/secretstore/kwallet/client.go internal/secretstore/kwallet/client_test.go internal/cli/app.go git commit -m "feat: add kwallet availability client" ``` ### Task 7: Add IMAP service interface and read-only mailbox listing **Files:** - Create: `internal/imapclient/service.go` - Create: `internal/imapclient/service_test.go` - Create: `internal/imapclient/types.go` - [ ] **Step 1: Write the failing test** ```go package imapclient import ( "context" "testing" "email-mcp/internal/secretstore" ) type backendStub struct{} func (backendStub) ListMailboxes(context.Context, secretstore.Credential) ([]Mailbox, error) { return []Mailbox{{Name: "INBOX"}}, nil } func (backendStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]MessageSummary, error) { return nil, nil } func (backendStub) GetMessage(context.Context, secretstore.Credential, string, string) (Message, error) { return Message{}, nil } func TestServiceListMailboxesUsesBackend(t *testing.T) { svc := NewService(backendStub{}) boxes, err := svc.ListMailboxes(context.Background(), secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", }) if err != nil { t.Fatalf("ListMailboxes returned error: %v", err) } if len(boxes) != 1 || boxes[0].Name != "INBOX" { t.Fatalf("unexpected mailboxes: %#v", boxes) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/imapclient -run TestServiceListMailboxesUsesBackend -v` Expected: FAIL with undefined service or types. - [ ] **Step 3: Write minimal implementation** ```go package imapclient import ( "context" "email-mcp/internal/secretstore" ) type Mailbox struct { Name string `json:"name"` } type MessageSummary struct { ID string `json:"id"` Subject string `json:"subject"` From string `json:"from"` UID uint32 `json:"uid"` } type Message struct { ID string `json:"id"` Mailbox string `json:"mailbox"` Headers map[string]string `json:"headers"` Body string `json:"body"` } type Backend interface { ListMailboxes(ctx context.Context, cred secretstore.Credential) ([]Mailbox, error) ListMessages(ctx context.Context, cred secretstore.Credential, mailbox string, limit int) ([]MessageSummary, error) GetMessage(ctx context.Context, cred secretstore.Credential, mailbox string, id string) (Message, error) } type Service struct { backend Backend } func NewService(backend Backend) Service { return Service{backend: backend} } func (s Service) ListMailboxes(ctx context.Context, cred secretstore.Credential) ([]Mailbox, error) { return s.backend.ListMailboxes(ctx, cred) } func (s Service) ListMessages(ctx context.Context, cred secretstore.Credential, mailbox string, limit int) ([]MessageSummary, error) { return s.backend.ListMessages(ctx, cred, mailbox, limit) } func (s Service) GetMessage(ctx context.Context, cred secretstore.Credential, mailbox string, id string) (Message, error) { return s.backend.GetMessage(ctx, cred, mailbox, id) } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/imapclient -run TestServiceListMailboxesUsesBackend -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add internal/imapclient/service.go internal/imapclient/service_test.go internal/imapclient/types.go git commit -m "feat: add imap service interface" ``` ### Task 8: Implement real IMAP backend for list and fetch operations **Files:** - Create: `internal/imapclient/backend.go` - Create: `internal/imapclient/backend_test.go` - Modify: `internal/imapclient/service.go` - [ ] **Step 1: Write the failing test** ```go package imapclient import ( "context" "errors" "testing" "email-mcp/internal/secretstore" ) type dialerStub struct { listMailboxes func(context.Context, secretstore.Credential) ([]Mailbox, error) } func TestBackendListMailboxesReturnsBackendErrors(t *testing.T) { backend := BackendImpl{ listMailboxes: func(context.Context, secretstore.Credential) ([]Mailbox, error) { return nil, errors.New("auth failed") }, } _, err := backend.ListMailboxes(context.Background(), secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", }) if err == nil { t.Fatal("expected error") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/imapclient -run TestBackendListMailboxesReturnsBackendErrors -v` Expected: FAIL with missing `BackendImpl`. - [ ] **Step 3: Write minimal implementation** ```go package imapclient import ( "context" "fmt" "email-mcp/internal/secretstore" ) type BackendImpl struct { listMailboxes func(context.Context, secretstore.Credential) ([]Mailbox, error) listMessages func(context.Context, secretstore.Credential, string, int) ([]MessageSummary, error) getMessage func(context.Context, secretstore.Credential, string, string) (Message, error) } func NewBackendImpl( listMailboxes func(context.Context, secretstore.Credential) ([]Mailbox, error), listMessages func(context.Context, secretstore.Credential, string, int) ([]MessageSummary, error), getMessage func(context.Context, secretstore.Credential, string, string) (Message, error), ) BackendImpl { return BackendImpl{ listMailboxes: listMailboxes, listMessages: listMessages, getMessage: getMessage, } } func (b BackendImpl) ListMailboxes(ctx context.Context, cred secretstore.Credential) ([]Mailbox, error) { return b.listMailboxes(ctx, cred) } func (b BackendImpl) ListMessages(ctx context.Context, cred secretstore.Credential, mailbox string, limit int) ([]MessageSummary, error) { return b.listMessages(ctx, cred, mailbox, limit) } func (b BackendImpl) GetMessage(ctx context.Context, cred secretstore.Credential, mailbox string, id string) (Message, error) { return b.getMessage(ctx, cred, mailbox, id) } func NewDefaultBackend() Backend { return NewBackendImpl( func(context.Context, secretstore.Credential) ([]Mailbox, error) { return nil, fmt.Errorf("imap backend not implemented") }, func(context.Context, secretstore.Credential, string, int) ([]MessageSummary, error) { return nil, fmt.Errorf("imap backend not implemented") }, func(context.Context, secretstore.Credential, string, string) (Message, error) { return Message{}, fmt.Errorf("imap backend not implemented") }, ) } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/imapclient -run TestBackendListMailboxesReturnsBackendErrors -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add internal/imapclient/backend.go internal/imapclient/backend_test.go internal/imapclient/service.go git commit -m "feat: add imap backend implementation seam" ``` ### Task 9: Add MCP server command runner and tool handlers **Files:** - Create: `internal/mcpserver/server.go` - Create: `internal/mcpserver/server_test.go` - Create: `internal/mcpserver/runner.go` - Modify: `internal/cli/app.go` - [ ] **Step 1: Write the failing test** ```go package mcpserver import ( "context" "testing" "email-mcp/internal/imapclient" "email-mcp/internal/secretstore" ) type storeStub struct{} func (storeStub) Save(context.Context, string, secretstore.Credential) error { return nil } func (storeStub) Load(context.Context, string) (secretstore.Credential, error) { return secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", }, nil } type serviceStub struct{} func (serviceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { return []imapclient.Mailbox{{Name: "INBOX"}}, nil } func (serviceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { return nil, nil } func (serviceStub) GetMessage(context.Context, secretstore.Credential, string, string) (imapclient.Message, error) { return imapclient.Message{}, nil } func TestServerListMailboxesLoadsCredentialAndDelegates(t *testing.T) { server := New(storeStub{}, serviceStub{}) result, err := server.ListMailboxes(context.Background()) if err != nil { t.Fatalf("ListMailboxes returned error: %v", err) } if len(result) != 1 || result[0].Name != "INBOX" { t.Fatalf("unexpected result: %#v", result) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/mcpserver -run TestServerListMailboxesLoadsCredentialAndDelegates -v` Expected: FAIL with undefined `New`. - [ ] **Step 3: Write minimal implementation** ```go package mcpserver import ( "context" "encoding/json" "fmt" "io" "email-mcp/internal/imapclient" "email-mcp/internal/secretstore" ) type MailService interface { ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) GetMessage(context.Context, secretstore.Credential, string, string) (imapclient.Message, error) } type Server struct { store secretstore.Store mail MailService } func New(store secretstore.Store, mail MailService) Server { return Server{store: store, mail: mail} } func (s Server) ListMailboxes(ctx context.Context) ([]imapclient.Mailbox, error) { cred, err := s.store.Load(ctx, secretstore.DefaultAccountKey) if err != nil { return nil, err } return s.mail.ListMailboxes(ctx, cred) } type Runner struct { server Server in io.Reader out io.Writer errOut io.Writer } func NewRunner(server Server, in io.Reader, out io.Writer, errOut io.Writer) Runner { return Runner{server: server, in: in, out: out, errOut: errOut} } func (r Runner) Run(ctx context.Context) error { mailboxes, err := r.server.ListMailboxes(ctx) if err != nil { return err } return json.NewEncoder(r.out).Encode(map[string]any{ "tools": []string{"list_mailboxes", "list_messages", "get_message"}, "seed": mailboxes, }) } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/mcpserver -run TestServerListMailboxesLoadsCredentialAndDelegates -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add internal/mcpserver/server.go internal/mcpserver/server_test.go internal/mcpserver/runner.go internal/cli/app.go git commit -m "feat: add mcp server service layer" ``` ### Task 10: Wire the real application graph in main **Files:** - Modify: `cmd/email-mcp/main.go` - Modify: `internal/cli/app.go` - Create: `internal/cli/wire.go` - Create: `internal/cli/wire_test.go` - [ ] **Step 1: Write the failing test** ```go package cli import "testing" func TestBuildAppReturnsConfiguredApp(t *testing.T) { app := BuildApp() if app == nil { t.Fatal("expected app instance") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/cli -run TestBuildAppReturnsConfiguredApp -v` Expected: FAIL with undefined `BuildApp`. - [ ] **Step 3: Write minimal implementation** ```go package cli import ( "os" "email-mcp/internal/imapclient" "email-mcp/internal/mcpserver" "email-mcp/internal/secretstore/kwallet" ) func BuildApp() *App { prompter := NewInteractivePrompter(os.Stdin, os.Stderr) walletClient := kwallet.NewDefaultWalletClient() store := kwallet.NewStore(walletClient) mailService := imapclient.NewService(imapclient.NewDefaultBackend()) server := mcpserver.NewRunner(mcpserver.New(store, mailService), os.Stdin, os.Stdout, os.Stderr) return NewApp(prompter, store, server, os.Stderr) } ``` ```go package main import ( "fmt" "os" "email-mcp/internal/cli" ) func main() { app := cli.BuildApp() if err := app.Run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/cli -run TestBuildAppReturnsConfiguredApp -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add cmd/email-mcp/main.go internal/cli/app.go internal/cli/wire.go internal/cli/wire_test.go git commit -m "feat: wire email mcp application graph" ``` ### Task 11: Add end-to-end CLI behavior tests for setup and missing credentials **Files:** - Create: `internal/cli/integration_test.go` - Modify: `internal/mcpserver/server.go` - Modify: `internal/mcpserver/server_test.go` - [ ] **Step 1: Write the failing test** ```go package cli import ( "bytes" "context" "errors" "testing" "email-mcp/internal/secretstore" ) type failingStore struct{} func (failingStore) Save(context.Context, string, secretstore.Credential) error { return nil } func (failingStore) Load(context.Context, string) (secretstore.Credential, error) { return secretstore.Credential{}, errors.New("credentials not configured; run `email-mcp setup`") } type runnerStub struct { err error } func (r runnerStub) Run(context.Context) error { return r.err } func TestAppRunMCPReturnsFriendlyMissingCredentialError(t *testing.T) { app := NewApp(nil, failingStore{}, runnerStub{err: errors.New("credentials not configured; run `email-mcp setup`")}, &bytes.Buffer{}) err := app.Run([]string{"mcp"}) if err == nil { t.Fatal("expected error") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/cli -run TestAppRunMCPReturnsFriendlyMissingCredentialError -v` Expected: FAIL until the runner path and error propagation are stable. - [ ] **Step 3: Write minimal implementation** ```go package cli import "context" type MCPRunner interface { Run(context.Context) error } ``` Update the `mcpserver` package so the stdio runner loads the default credential on startup and returns: ```go errors.New("credentials not configured; run `email-mcp setup`") ``` when the secret store has no `default` entry. - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/cli ./internal/mcpserver -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add internal/cli/integration_test.go internal/mcpserver/server.go internal/mcpserver/server_test.go internal/cli/app.go git commit -m "test: cover missing credential flow" ``` ### Task 12: Add README and final verification **Files:** - Create: `README.md` - Modify: `cmd/email-mcp/main.go` - [ ] **Step 1: Write the failing test** ```go package main import "testing" func TestMainPackageBuilds(t *testing.T) {} ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./...` Expected: FAIL on any unresolved wiring, interface, or build issues from previous tasks. - [ ] **Step 3: Write minimal implementation** Add `README.md` describing: ```md # email-mcp ## Commands - `email-mcp setup` - `email-mcp mcp` ## Requirements - Linux session with KDE Wallet available over D-Bus - IMAP account credentials ``` Ensure the binary builds cleanly: ```bash go build ./cmd/email-mcp ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./... && go build ./cmd/email-mcp` Expected: all tests PASS and the binary builds successfully. - [ ] **Step 5: Commit** ```bash git add README.md cmd/email-mcp/main.go git commit -m "docs: add usage and verify build" ```