feat: add mcp server runner and tool handlers
This commit is contained in:
parent
656d0d7f91
commit
679abbe328
4 changed files with 527 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"email-mcp/internal/mcpserver"
|
||||||
"email-mcp/internal/secretstore"
|
"email-mcp/internal/secretstore"
|
||||||
"email-mcp/internal/secretstore/kwallet"
|
"email-mcp/internal/secretstore/kwallet"
|
||||||
)
|
)
|
||||||
|
|
@ -88,6 +89,8 @@ func mapAppError(err error) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
case errors.Is(err, mcpserver.ErrCredentialsNotConfigured):
|
||||||
|
return newUserFacingError("credentials not configured; run `email-mcp setup`", err)
|
||||||
case errors.Is(err, kwallet.ErrKWalletUnavailable):
|
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)
|
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):
|
case errors.Is(err, kwallet.ErrKWalletDisabled):
|
||||||
|
|
|
||||||
17
internal/cli/app_task9_test.go
Normal file
17
internal/cli/app_task9_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
257
internal/mcpserver/server.go
Normal file
257
internal/mcpserver/server.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
250
internal/mcpserver/server_test.go
Normal file
250
internal/mcpserver/server_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue