email-mcp/docs/superpowers/plans/2026-04-10-email-mcp.md
2026-04-10 09:32:21 +02:00

1279 lines
30 KiB
Markdown

# 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 <setup|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 <setup|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"
```