email-mcp/internal/mcpserver/server.go

340 lines
8.3 KiB
Go
Raw Normal View History

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"`
2026-04-10 10:10:42 +00:00
Limit *int `json:"limit,omitempty"`
}
type getMessageArguments struct {
2026-04-10 10:10:42 +00:00
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{
2026-04-10 10:10:42 +00:00
"mailbox": map[string]any{
"type": "string",
"minLength": 1,
"pattern": "\\S",
2026-04-10 10:10:42 +00:00
},
"limit": map[string]any{
"type": "integer",
"default": imapclient.DefaultListMessagesLimit,
2026-04-10 10:10:42 +00:00
"minimum": 1,
"maximum": imapclient.MaxListMessagesLimit,
2026-04-10 10:10:42 +00:00
},
},
2026-04-10 10:10:42 +00:00
"required": []string{"mailbox"},
"additionalProperties": false,
},
},
{
Name: "get_message",
Description: "Fetch a single message by mailbox and IMAP UID.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
2026-04-10 10:10:42 +00:00
"mailbox": map[string]any{
"type": "string",
"minLength": 1,
"pattern": "\\S",
2026-04-10 10:10:42 +00:00
},
"uid": map[string]any{
"type": "integer",
"minimum": 1,
},
},
2026-04-10 10:10:42 +00:00
"required": []string{"mailbox", "uid"},
"additionalProperties": false,
},
},
}
}
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
}
2026-04-10 10:10:42 +00:00
stopCancelRead := r.closeInputOnCancel(ctx)
defer stopCancelRead()
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 {
2026-04-10 10:10:42 +00:00
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
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
}
2026-04-10 10:10:42 +00:00
limit, err := normalizeListMessagesLimit(args.Limit)
if err != nil {
return nil, err
}
return s.listMessages(ctx, cred, args.Mailbox, limit)
case "get_message":
var args getMessageArguments
if err := decodeArguments(rawArgs, &args); err != nil {
return nil, err
}
2026-04-10 10:10:42 +00:00
uid, err := validateMessageUID(args.UID)
if err != nil {
return nil, err
}
return s.getMessage(ctx, cred, args.Mailbox, 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")
}
2026-04-10 10:10:42 +00:00
mailbox, err := validateMailbox(mailbox)
if err != nil {
return nil, err
}
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")
}
2026-04-10 10:10:42 +00:00
mailbox, err := validateMailbox(mailbox)
if err != nil {
return imapclient.Message{}, err
}
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 {
2026-04-10 10:35:08 +00:00
if errors.Is(err, kwallet.ErrCredentialNotFound) || errors.Is(err, ErrCredentialsNotConfigured) {
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("{}")
}
2026-04-10 10:10:42 +00:00
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(dest); err != nil {
return fmt.Errorf("invalid tool arguments: %w", err)
}
return nil
}
2026-04-10 10:10:42 +00:00
func validateMailbox(mailbox string) (string, error) {
mailbox = strings.TrimSpace(mailbox)
if mailbox == "" {
return "", fmt.Errorf("mailbox is required")
}
return mailbox, nil
}
func normalizeListMessagesLimit(limit *int) (int, error) {
if limit == nil {
return imapclient.DefaultListMessagesLimit, nil
2026-04-10 10:10:42 +00:00
}
if *limit < 1 || *limit > imapclient.MaxListMessagesLimit {
return 0, fmt.Errorf("limit must be between 1 and %d", imapclient.MaxListMessagesLimit)
2026-04-10 10:10:42 +00:00
}
return *limit, nil
}
func validateMessageUID(uid *uint32) (uint32, error) {
if uid == nil {
return 0, fmt.Errorf("uid is required")
}
if *uid == 0 {
return 0, fmt.Errorf("uid must be greater than zero")
}
return *uid, nil
}
func (r Runner) closeInputOnCancel(ctx context.Context) func() {
closer, ok := r.in.(io.Closer)
if !ok {
return func() {}
}
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = closer.Close()
case <-done:
}
}()
return func() {
close(done)
}
}