From 679abbe32864a4be716dc75a7abc5c3d1e45b154 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Fri, 10 Apr 2026 11:59:41 +0200 Subject: [PATCH] feat: add mcp server runner and tool handlers --- internal/cli/app.go | 3 + internal/cli/app_task9_test.go | 17 ++ internal/mcpserver/server.go | 257 ++++++++++++++++++++++++++++++ internal/mcpserver/server_test.go | 250 +++++++++++++++++++++++++++++ 4 files changed, 527 insertions(+) create mode 100644 internal/cli/app_task9_test.go create mode 100644 internal/mcpserver/server.go create mode 100644 internal/mcpserver/server_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 406220d..2d166e3 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -7,6 +7,7 @@ import ( "io" "os" + "email-mcp/internal/mcpserver" "email-mcp/internal/secretstore" "email-mcp/internal/secretstore/kwallet" ) @@ -88,6 +89,8 @@ func mapAppError(err error) error { } switch { + case errors.Is(err, mcpserver.ErrCredentialsNotConfigured): + return newUserFacingError("credentials not configured; run `email-mcp setup`", err) case errors.Is(err, kwallet.ErrKWalletUnavailable): return newUserFacingError("kwallet is not available; make sure KDE Wallet is installed and your session D-Bus is running", err) case errors.Is(err, kwallet.ErrKWalletDisabled): diff --git a/internal/cli/app_task9_test.go b/internal/cli/app_task9_test.go new file mode 100644 index 0000000..0c96d30 --- /dev/null +++ b/internal/cli/app_task9_test.go @@ -0,0 +1,17 @@ +package cli + +import ( + "testing" + + "email-mcp/internal/mcpserver" +) + +func TestMapAppErrorMapsMCPMissingCredentialError(t *testing.T) { + err := mapAppError(mcpserver.ErrCredentialsNotConfigured) + if err == nil { + t.Fatal("expected mapped error") + } + if err.Error() != "credentials not configured; run `email-mcp setup`" { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..4e76803 --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,257 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "email-mcp/internal/imapclient" + "email-mcp/internal/secretstore" + "email-mcp/internal/secretstore/kwallet" +) + +var ErrCredentialsNotConfigured = errors.New("credentials not configured; run `email-mcp setup`") + +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, uint32) (imapclient.Message, error) +} + +type Server struct { + store secretstore.Store + mail MailService +} + +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"input_schema,omitempty"` +} + +type Runner struct { + server Server + in io.Reader + out io.Writer + errOut io.Writer +} + +type toolRequest struct { + Tool string `json:"tool"` + Arguments json.RawMessage `json:"arguments,omitempty"` +} + +type listMessagesArguments struct { + Mailbox string `json:"mailbox"` + Limit int `json:"limit,omitempty"` +} + +type getMessageArguments struct { + Mailbox string `json:"mailbox"` + UID uint32 `json:"uid"` +} + +func New(store secretstore.Store, mail MailService) Server { + return Server{ + store: store, + mail: mail, + } +} + +func NewRunner(server Server, in io.Reader, out io.Writer, errOut io.Writer) Runner { + if out == nil { + out = io.Discard + } + if errOut == nil { + errOut = io.Discard + } + + return Runner{ + server: server, + in: in, + out: out, + errOut: errOut, + } +} + +func (s Server) Tools() []Tool { + return []Tool{ + { + Name: "list_mailboxes", + Description: "List visible IMAP mailboxes for the configured account.", + }, + { + Name: "list_messages", + Description: "List recent messages from a mailbox using IMAP UIDs.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "mailbox": map[string]any{"type": "string"}, + "limit": map[string]any{"type": "integer"}, + }, + "required": []string{"mailbox"}, + }, + }, + { + Name: "get_message", + Description: "Fetch a single message by mailbox and IMAP UID.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "mailbox": map[string]any{"type": "string"}, + "uid": map[string]any{"type": "integer"}, + }, + "required": []string{"mailbox", "uid"}, + }, + }, + } +} + +func (s Server) ListMailboxes(ctx context.Context) ([]imapclient.Mailbox, error) { + cred, err := s.loadCredential(ctx) + if err != nil { + return nil, err + } + return s.listMailboxes(ctx, cred) +} + +func (s Server) ListMessages(ctx context.Context, mailbox string, limit int) ([]imapclient.MessageSummary, error) { + cred, err := s.loadCredential(ctx) + if err != nil { + return nil, err + } + return s.listMessages(ctx, cred, mailbox, limit) +} + +func (s Server) GetMessage(ctx context.Context, mailbox string, uid uint32) (imapclient.Message, error) { + cred, err := s.loadCredential(ctx) + if err != nil { + return imapclient.Message{}, err + } + return s.getMessage(ctx, cred, mailbox, uid) +} + +func (r Runner) Run(ctx context.Context) error { + cred, err := r.server.loadCredential(ctx) + if err != nil { + return err + } + + encoder := json.NewEncoder(r.out) + if err := encoder.Encode(map[string]any{"tools": r.server.Tools()}); err != nil { + return err + } + if r.in == nil { + return nil + } + + decoder := json.NewDecoder(r.in) + for { + if err := ctx.Err(); err != nil { + return err + } + + var request toolRequest + if err := decoder.Decode(&request); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + + result, err := r.server.handleTool(ctx, cred, request.Tool, request.Arguments) + if err != nil { + if encodeErr := encoder.Encode(map[string]any{"error": err.Error()}); encodeErr != nil { + return encodeErr + } + continue + } + if err := encoder.Encode(map[string]any{"result": result}); err != nil { + return err + } + } +} + +func (s Server) handleTool(ctx context.Context, cred secretstore.Credential, name string, rawArgs json.RawMessage) (any, error) { + switch name { + case "list_mailboxes": + return s.listMailboxes(ctx, cred) + case "list_messages": + var args listMessagesArguments + if err := decodeArguments(rawArgs, &args); err != nil { + return nil, err + } + return s.listMessages(ctx, cred, args.Mailbox, args.Limit) + case "get_message": + var args getMessageArguments + if err := decodeArguments(rawArgs, &args); err != nil { + return nil, err + } + return s.getMessage(ctx, cred, args.Mailbox, args.UID) + default: + return nil, fmt.Errorf("unknown tool: %s", name) + } +} + +func (s Server) listMailboxes(ctx context.Context, cred secretstore.Credential) ([]imapclient.Mailbox, error) { + if s.mail == nil { + return nil, fmt.Errorf("mail service is not configured") + } + return s.mail.ListMailboxes(ctx, cred) +} + +func (s Server) listMessages(ctx context.Context, cred secretstore.Credential, mailbox string, limit int) ([]imapclient.MessageSummary, error) { + if s.mail == nil { + return nil, fmt.Errorf("mail service is not configured") + } + mailbox = strings.TrimSpace(mailbox) + if mailbox == "" { + return nil, fmt.Errorf("mailbox is required") + } + return s.mail.ListMessages(ctx, cred, mailbox, limit) +} + +func (s Server) getMessage(ctx context.Context, cred secretstore.Credential, mailbox string, uid uint32) (imapclient.Message, error) { + if s.mail == nil { + return imapclient.Message{}, fmt.Errorf("mail service is not configured") + } + mailbox = strings.TrimSpace(mailbox) + if mailbox == "" { + return imapclient.Message{}, fmt.Errorf("mailbox is required") + } + if uid == 0 { + return imapclient.Message{}, fmt.Errorf("uid must be greater than zero") + } + return s.mail.GetMessage(ctx, cred, mailbox, uid) +} + +func (s Server) loadCredential(ctx context.Context) (secretstore.Credential, error) { + if s.store == nil { + return secretstore.Credential{}, fmt.Errorf("secret store is not configured") + } + + cred, err := s.store.Load(ctx, secretstore.DefaultAccountKey) + if err != nil { + if errors.Is(err, kwallet.ErrCredentialNotFound) { + return secretstore.Credential{}, ErrCredentialsNotConfigured + } + return secretstore.Credential{}, err + } + if err := cred.Validate(); err != nil { + return secretstore.Credential{}, fmt.Errorf("default credential is invalid: %w", err) + } + return cred, nil +} + +func decodeArguments(raw json.RawMessage, dest any) error { + if len(raw) == 0 { + raw = []byte("{}") + } + if err := json.Unmarshal(raw, dest); err != nil { + return fmt.Errorf("invalid tool arguments: %w", err) + } + return nil +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..37f3be4 --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -0,0 +1,250 @@ +package mcpserver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "email-mcp/internal/imapclient" + "email-mcp/internal/secretstore" + "email-mcp/internal/secretstore/kwallet" +) + +type storeStub struct { + credential secretstore.Credential + loadErr error + loadCalls int + loadKey string +} + +func (s *storeStub) Save(context.Context, string, secretstore.Credential) error { + return nil +} + +func (s *storeStub) Load(_ context.Context, key string) (secretstore.Credential, error) { + s.loadCalls++ + s.loadKey = key + if s.loadErr != nil { + return secretstore.Credential{}, s.loadErr + } + return s.credential, nil +} + +type serviceStub struct { + listMailboxes func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) + listMessages func(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) + getMessage func(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) +} + +func (s serviceStub) ListMailboxes(ctx context.Context, cred secretstore.Credential) ([]imapclient.Mailbox, error) { + return s.listMailboxes(ctx, cred) +} + +func (s serviceStub) ListMessages(ctx context.Context, cred secretstore.Credential, mailbox string, limit int) ([]imapclient.MessageSummary, error) { + return s.listMessages(ctx, cred, mailbox, limit) +} + +func (s serviceStub) GetMessage(ctx context.Context, cred secretstore.Credential, mailbox string, uid uint32) (imapclient.Message, error) { + return s.getMessage(ctx, cred, mailbox, uid) +} + +func TestServerListMailboxesLoadsCredentialAndDelegates(t *testing.T) { + store := &storeStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "secret", + }, + } + server := New(store, serviceStub{ + listMailboxes: func(_ context.Context, cred secretstore.Credential) ([]imapclient.Mailbox, error) { + if cred.Host != "imap.example.com" || cred.Username != "alice" || cred.Password != "secret" { + t.Fatalf("unexpected credential: %#v", cred) + } + return []imapclient.Mailbox{{Name: "INBOX"}}, nil + }, + listMessages: func(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { + t.Fatal("ListMessages should not be called") + return nil, nil + }, + getMessage: func(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { + t.Fatal("GetMessage should not be called") + return imapclient.Message{}, nil + }, + }) + + result, err := server.ListMailboxes(context.Background()) + if err != nil { + t.Fatalf("ListMailboxes returned error: %v", err) + } + if store.loadCalls != 1 { + t.Fatalf("expected credential to be loaded once, got %d", store.loadCalls) + } + if store.loadKey != secretstore.DefaultAccountKey { + t.Fatalf("expected load key %q, got %q", secretstore.DefaultAccountKey, store.loadKey) + } + if len(result) != 1 || result[0].Name != "INBOX" { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestServerListMessagesLoadsCredentialAndDelegates(t *testing.T) { + store := &storeStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "secret", + }, + } + server := New(store, serviceStub{ + listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { + t.Fatal("ListMailboxes should not be called") + return nil, nil + }, + listMessages: func(_ context.Context, cred secretstore.Credential, mailbox string, limit int) ([]imapclient.MessageSummary, error) { + if cred.Host != "imap.example.com" || mailbox != "INBOX" || limit != 5 { + t.Fatalf("unexpected call: cred=%#v mailbox=%q limit=%d", cred, mailbox, limit) + } + return []imapclient.MessageSummary{{UID: 42, Subject: "hello", From: "alice@example.com"}}, nil + }, + getMessage: func(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { + t.Fatal("GetMessage should not be called") + return imapclient.Message{}, nil + }, + }) + + result, err := server.ListMessages(context.Background(), "INBOX", 5) + if err != nil { + t.Fatalf("ListMessages returned error: %v", err) + } + if len(result) != 1 || result[0].UID != 42 { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestServerGetMessageUsesUIDContract(t *testing.T) { + store := &storeStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "secret", + }, + } + server := New(store, serviceStub{ + listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { + t.Fatal("ListMailboxes should not be called") + return nil, nil + }, + listMessages: func(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { + t.Fatal("ListMessages should not be called") + return nil, nil + }, + getMessage: func(_ context.Context, cred secretstore.Credential, mailbox string, uid uint32) (imapclient.Message, error) { + if cred.Host != "imap.example.com" || mailbox != "INBOX" || uid != 42 { + t.Fatalf("unexpected call: cred=%#v mailbox=%q uid=%d", cred, mailbox, uid) + } + return imapclient.Message{ + UID: 42, + Mailbox: "INBOX", + Body: "body", + }, nil + }, + }) + + message, err := server.GetMessage(context.Background(), "INBOX", 42) + if err != nil { + t.Fatalf("GetMessage returned error: %v", err) + } + if message.UID != 42 || message.Mailbox != "INBOX" { + t.Fatalf("unexpected message: %#v", message) + } +} + +func TestRunnerRunWritesToolManifestAndHandlesRequests(t *testing.T) { + store := &storeStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "secret", + }, + } + input := bytes.NewBufferString("{\"tool\":\"list_messages\",\"arguments\":{\"mailbox\":\"INBOX\",\"limit\":5}}\n") + output := &bytes.Buffer{} + runner := NewRunner(New(store, serviceStub{ + listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { + t.Fatal("ListMailboxes should not be called") + return nil, nil + }, + listMessages: func(_ context.Context, cred secretstore.Credential, mailbox string, limit int) ([]imapclient.MessageSummary, error) { + if cred.Host != "imap.example.com" || mailbox != "INBOX" || limit != 5 { + t.Fatalf("unexpected call: cred=%#v mailbox=%q limit=%d", cred, mailbox, limit) + } + return []imapclient.MessageSummary{{UID: 42, Subject: "hello", From: "alice@example.com"}}, nil + }, + getMessage: func(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { + t.Fatal("GetMessage should not be called") + return imapclient.Message{}, nil + }, + }), input, output, &bytes.Buffer{}) + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if store.loadCalls != 1 { + t.Fatalf("expected credential preload once, got %d", store.loadCalls) + } + + decoder := json.NewDecoder(output) + + var manifest struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } + if err := decoder.Decode(&manifest); err != nil { + t.Fatalf("failed to decode manifest: %v", err) + } + if len(manifest.Tools) != 3 { + t.Fatalf("expected 3 tools, got %#v", manifest.Tools) + } + if manifest.Tools[0].Name != "list_mailboxes" || manifest.Tools[1].Name != "list_messages" || manifest.Tools[2].Name != "get_message" { + t.Fatalf("unexpected tool manifest: %#v", manifest.Tools) + } + + var response struct { + Result []imapclient.MessageSummary `json:"result"` + } + if err := decoder.Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(response.Result) != 1 || response.Result[0].UID != 42 { + t.Fatalf("unexpected response: %#v", response.Result) + } +} + +func TestRunnerRunReturnsFriendlyMissingCredentialError(t *testing.T) { + store := &storeStub{ + loadErr: kwallet.ErrCredentialNotFound, + } + runner := NewRunner(New(store, serviceStub{ + listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { + t.Fatal("ListMailboxes should not be called") + return nil, nil + }, + listMessages: func(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { + t.Fatal("ListMessages should not be called") + return nil, nil + }, + getMessage: func(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { + t.Fatal("GetMessage should not be called") + return imapclient.Message{}, nil + }, + }), bytes.NewBuffer(nil), &bytes.Buffer{}, &bytes.Buffer{}) + + err := runner.Run(context.Background()) + if !errors.Is(err, ErrCredentialsNotConfigured) { + t.Fatalf("expected missing credential error, got %v", err) + } +}