From 59b6cb2f182c54c43206a6673c27d65f9833156a Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Fri, 10 Apr 2026 09:32:21 +0200 Subject: [PATCH] docs: add email mcp implementation plan --- .../superpowers/plans/2026-04-10-email-mcp.md | 1279 +++++++++++++++++ 1 file changed, 1279 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-10-email-mcp.md diff --git a/docs/superpowers/plans/2026-04-10-email-mcp.md b/docs/superpowers/plans/2026-04-10-email-mcp.md new file mode 100644 index 0000000..f91e6ad --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-email-mcp.md @@ -0,0 +1,1279 @@ +# 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" +```