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

30 KiB

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

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
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])
	}
}
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
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

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
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
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

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
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()
}
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
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

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
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
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

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
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
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

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
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
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

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
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
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

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
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
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

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
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
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

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
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)
}
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
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

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
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:

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
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

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:

# 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:

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
git add README.md cmd/email-mcp/main.go
git commit -m "docs: add usage and verify build"