package mcpserver import ( "bytes" "context" "encoding/json" "errors" "io" "testing" "time" "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("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test-client\",\"version\":\"1.0.0\"}}}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"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 initializeResponse struct { JSONRPC string `json:"jsonrpc"` ID int `json:"id"` Result struct { ProtocolVersion string `json:"protocolVersion"` Capabilities map[string]any `json:"capabilities"` ServerInfo struct { Name string `json:"name"` Version string `json:"version"` } `json:"serverInfo"` } `json:"result"` } if err := decoder.Decode(&initializeResponse); err != nil { t.Fatalf("failed to decode initialize response: %v", err) } if initializeResponse.JSONRPC != "2.0" { t.Fatalf("expected jsonrpc 2.0, got %#v", initializeResponse) } if initializeResponse.ID != 1 { t.Fatalf("expected initialize response id 1, got %#v", initializeResponse) } if initializeResponse.Result.ProtocolVersion != "2025-03-26" { t.Fatalf("expected negotiated protocol version, got %#v", initializeResponse.Result) } if _, ok := initializeResponse.Result.Capabilities["tools"]; !ok { t.Fatalf("expected tools capability, got %#v", initializeResponse.Result.Capabilities) } if initializeResponse.Result.ServerInfo.Name == "" || initializeResponse.Result.ServerInfo.Version == "" { t.Fatalf("expected server info, got %#v", initializeResponse.Result.ServerInfo) } var listResponse struct { JSONRPC string `json:"jsonrpc"` ID int `json:"id"` Result struct { Tools []map[string]any `json:"tools"` } `json:"result"` } if err := decoder.Decode(&listResponse); err != nil { t.Fatalf("failed to decode tools/list response: %v", err) } if listResponse.JSONRPC != "2.0" || listResponse.ID != 2 { t.Fatalf("unexpected tools/list response envelope: %#v", listResponse) } if len(listResponse.Result.Tools) != 3 { t.Fatalf("expected 3 tools, got %#v", listResponse.Result.Tools) } if listResponse.Result.Tools[0]["name"] != "list_mailboxes" || listResponse.Result.Tools[1]["name"] != "list_messages" || listResponse.Result.Tools[2]["name"] != "get_message" { t.Fatalf("unexpected tool manifest: %#v", listResponse.Result.Tools) } if _, ok := listResponse.Result.Tools[1]["inputSchema"]; !ok { t.Fatalf("expected inputSchema field in tools/list response, got %#v", listResponse.Result.Tools[1]) } var response struct { JSONRPC string `json:"jsonrpc"` ID int `json:"id"` Result struct { Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` IsError bool `json:"isError"` } `json:"result"` } if err := decoder.Decode(&response); err != nil { t.Fatalf("failed to decode response: %v", err) } if response.JSONRPC != "2.0" || response.ID != 3 { t.Fatalf("unexpected tools/call response envelope: %#v", response) } if response.Result.IsError { t.Fatalf("expected successful tools/call result, got %#v", response.Result) } if len(response.Result.Content) != 1 || response.Result.Content[0].Type != "text" { t.Fatalf("unexpected tools/call content: %#v", response.Result.Content) } var messages []imapclient.MessageSummary if err := json.Unmarshal([]byte(response.Result.Content[0].Text), &messages); err != nil { t.Fatalf("failed to decode tools/call text payload: %v", err) } if len(messages) != 1 || messages[0].UID != 42 { t.Fatalf("unexpected response: %#v", messages) } } func TestRunnerRunAcceptsClaudeCodeProtocolVersion(t *testing.T) { store := &storeStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", }, } input := bytes.NewBufferString("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"claude-code\",\"version\":\"1.0.0\"}}}\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, 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 }, }), input, output, &bytes.Buffer{}) if err := runner.Run(context.Background()); err != nil { t.Fatalf("Run returned error: %v", err) } var response struct { JSONRPC string `json:"jsonrpc"` ID int `json:"id"` Result struct { ProtocolVersion string `json:"protocolVersion"` } `json:"result"` Error *struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.NewDecoder(output).Decode(&response); err != nil { t.Fatalf("failed to decode initialize response: %v", err) } if response.Error != nil { t.Fatalf("expected initialize to succeed, got error %#v", response.Error) } if response.JSONRPC != "2.0" || response.ID != 1 { t.Fatalf("unexpected response envelope: %#v", response) } if response.Result.ProtocolVersion != "2024-11-05" { t.Fatalf("expected negotiated protocol version 2024-11-05, got %#v", response.Result) } } func TestRunnerRunReturnsFriendlyMissingCredentialError(t *testing.T) { store := &storeStub{ loadErr: kwallet.ErrCredentialNotFound, } input := bytes.NewBufferString( "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1.0.0\"}}}\n" + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"list_mailboxes\"}}\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, 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 }, }), input, output, &bytes.Buffer{}) if err := runner.Run(context.Background()); err != nil { t.Fatalf("Run returned error: %v", err) } decoder := json.NewDecoder(output) // Skip initialize response var initResp json.RawMessage if err := decoder.Decode(&initResp); err != nil { t.Fatalf("failed to decode initialize response: %v", err) } // Check tool call response contains credential error var toolResp struct { ID int `json:"id"` Result struct { Content []struct { Text string `json:"text"` } `json:"content"` IsError bool `json:"isError"` } `json:"result"` } if err := decoder.Decode(&toolResp); err != nil { t.Fatalf("failed to decode tool call response: %v", err) } if !toolResp.Result.IsError { t.Fatal("expected isError true for missing credentials") } if len(toolResp.Result.Content) == 0 || toolResp.Result.Content[0].Text != ErrCredentialsNotConfigured.Error() { t.Fatalf("expected credential error message, got %#v", toolResp.Result) } } func TestRunnerRunReturnsFriendlyMissingCredentialErrorWhenStoreAlreadyTranslatedIt(t *testing.T) { store := &storeStub{ loadErr: ErrCredentialsNotConfigured, } input := bytes.NewBufferString( "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1.0.0\"}}}\n" + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"list_mailboxes\"}}\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, 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 }, }), input, output, &bytes.Buffer{}) if err := runner.Run(context.Background()); err != nil { t.Fatalf("Run returned error: %v", err) } decoder := json.NewDecoder(output) // Skip initialize response var initResp json.RawMessage if err := decoder.Decode(&initResp); err != nil { t.Fatalf("failed to decode initialize response: %v", err) } // Check tool call response contains credential error var toolResp struct { ID int `json:"id"` Result struct { Content []struct { Text string `json:"text"` } `json:"content"` IsError bool `json:"isError"` } `json:"result"` } if err := decoder.Decode(&toolResp); err != nil { t.Fatalf("failed to decode tool call response: %v", err) } if !toolResp.Result.IsError { t.Fatal("expected isError true for missing credentials") } if len(toolResp.Result.Content) == 0 || toolResp.Result.Content[0].Text != ErrCredentialsNotConfigured.Error() { t.Fatalf("expected credential error message, got %#v", toolResp.Result) } } func TestServerToolsAdvertiseValidatedArgumentContracts(t *testing.T) { tools := New(&storeStub{}, serviceStub{}).Tools() if len(tools) != 3 { t.Fatalf("expected 3 tools, got %d", len(tools)) } listMessages := tools[1] if listMessages.Name != "list_messages" { t.Fatalf("unexpected tool ordering: %#v", tools) } if got := listMessages.InputSchema["type"]; got != "object" { t.Fatalf("expected object schema, got %#v", got) } listProps, ok := listMessages.InputSchema["properties"].(map[string]any) if !ok { t.Fatalf("expected properties map, got %#v", listMessages.InputSchema["properties"]) } limitSchema, ok := listProps["limit"].(map[string]any) if !ok { t.Fatalf("expected limit schema, got %#v", listProps["limit"]) } mailboxSchema, ok := listProps["mailbox"].(map[string]any) if !ok { t.Fatalf("expected mailbox schema, got %#v", listProps["mailbox"]) } if got := mailboxSchema["pattern"]; got != "\\S" { t.Fatalf("expected mailbox pattern %q, got %#v", "\\S", got) } if got := limitSchema["default"]; got != float64(imapclient.DefaultListMessagesLimit) && got != imapclient.DefaultListMessagesLimit { t.Fatalf("expected limit default %d, got %#v", imapclient.DefaultListMessagesLimit, got) } if got := limitSchema["minimum"]; got != float64(1) && got != 1 { t.Fatalf("expected limit minimum 1, got %#v", got) } if got := limitSchema["maximum"]; got != float64(imapclient.MaxListMessagesLimit) && got != imapclient.MaxListMessagesLimit { t.Fatalf("expected limit maximum %d, got %#v", imapclient.MaxListMessagesLimit, got) } getMessage := tools[2] if getMessage.Name != "get_message" { t.Fatalf("unexpected tool ordering: %#v", tools) } getProps, ok := getMessage.InputSchema["properties"].(map[string]any) if !ok { t.Fatalf("expected get_message properties map, got %#v", getMessage.InputSchema["properties"]) } getMailboxSchema, ok := getProps["mailbox"].(map[string]any) if !ok { t.Fatalf("expected get_message mailbox schema, got %#v", getProps["mailbox"]) } if got := getMailboxSchema["pattern"]; got != "\\S" { t.Fatalf("expected mailbox pattern %q, got %#v", "\\S", got) } uidSchema, ok := getProps["uid"].(map[string]any) if !ok { t.Fatalf("expected uid schema, got %#v", getProps["uid"]) } if got := uidSchema["minimum"]; got != float64(1) && got != 1 { t.Fatalf("expected uid minimum 1, got %#v", got) } } func TestRunnerRunReturnsValidationErrorsForInvalidRequests(t *testing.T) { store := &storeStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", }, } input := bytes.NewBufferString("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test-client\",\"version\":\"1.0.0\"}}}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"list_messages\",\"arguments\":{\"mailbox\":\"INBOX\",\"limit\":0}}}\n{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"get_message\",\"arguments\":{\"mailbox\":\"INBOX\"}}}\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, 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 }, }), input, output, &bytes.Buffer{}) if err := runner.Run(context.Background()); err != nil { t.Fatalf("Run returned error: %v", err) } decoder := json.NewDecoder(output) if err := decoder.Decode(&struct { JSONRPC string `json:"jsonrpc"` ID int `json:"id"` }{}); err != nil { t.Fatalf("failed to decode initialize response: %v", err) } var firstResponse struct { JSONRPC string `json:"jsonrpc"` ID int `json:"id"` Error struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` } if err := decoder.Decode(&firstResponse); err != nil { t.Fatalf("failed to decode first error response: %v", err) } if firstResponse.JSONRPC != "2.0" || firstResponse.ID != 2 { t.Fatalf("unexpected first error envelope: %#v", firstResponse) } if firstResponse.Error.Code != -32602 { t.Fatalf("expected invalid params code, got %#v", firstResponse) } if firstResponse.Error.Message != "limit must be between 1 and 50" { t.Fatalf("unexpected first error: %#v", firstResponse) } var secondResponse struct { JSONRPC string `json:"jsonrpc"` ID int `json:"id"` Error struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` } if err := decoder.Decode(&secondResponse); err != nil { t.Fatalf("failed to decode second error response: %v", err) } if secondResponse.JSONRPC != "2.0" || secondResponse.ID != 3 { t.Fatalf("unexpected second error envelope: %#v", secondResponse) } if secondResponse.Error.Code != -32602 { t.Fatalf("expected invalid params code, got %#v", secondResponse) } if secondResponse.Error.Message != "uid is required" { t.Fatalf("unexpected second error: %#v", secondResponse) } } func TestRunnerRunRejectsWhitespaceOnlyMailboxValues(t *testing.T) { store := &storeStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", }, } input := bytes.NewBufferString("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test-client\",\"version\":\"1.0.0\"}}}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"list_messages\",\"arguments\":{\"mailbox\":\" \"}}}\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, 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 }, }), input, output, &bytes.Buffer{}) if err := runner.Run(context.Background()); err != nil { t.Fatalf("Run returned error: %v", err) } decoder := json.NewDecoder(output) if err := decoder.Decode(&struct { JSONRPC string `json:"jsonrpc"` ID int `json:"id"` }{}); err != nil { t.Fatalf("failed to decode initialize response: %v", err) } var response struct { JSONRPC string `json:"jsonrpc"` ID int `json:"id"` Error struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` } if err := decoder.Decode(&response); err != nil { t.Fatalf("failed to decode error response: %v", err) } if response.JSONRPC != "2.0" || response.ID != 2 { t.Fatalf("unexpected error envelope: %#v", response) } if response.Error.Code != -32602 { t.Fatalf("expected invalid params code, got %#v", response) } if response.Error.Message != "mailbox is required" { t.Fatalf("unexpected error: %#v", response) } } func TestRunnerRunAppliesDefaultLimitWhenOmitted(t *testing.T) { store := &storeStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", }, } input := bytes.NewBufferString("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test-client\",\"version\":\"1.0.0\"}}}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"list_messages\",\"arguments\":{\"mailbox\":\"INBOX\"}}}\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 != imapclient.DefaultListMessagesLimit { 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) } } func TestRunnerRunRejectsRequestsBeforeInitialize(t *testing.T) { store := &storeStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", }, } input := bytes.NewBufferString("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\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, 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 }, }), input, output, &bytes.Buffer{}) if err := runner.Run(context.Background()); err != nil { t.Fatalf("Run returned error: %v", err) } var response struct { JSONRPC string `json:"jsonrpc"` ID int `json:"id"` Error struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.NewDecoder(output).Decode(&response); err != nil { t.Fatalf("failed to decode response: %v", err) } if response.JSONRPC != "2.0" || response.ID != 1 { t.Fatalf("unexpected response envelope: %#v", response) } if response.Error.Code == 0 || response.Error.Message == "" { t.Fatalf("expected protocol error before initialization, got %#v", response) } } func TestRunnerRunStopsWhenContextCanceledWhileWaitingForInput(t *testing.T) { store := &storeStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", }, } input := newBlockingReadCloser() 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 }, }), input, &bytes.Buffer{}, &bytes.Buffer{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) go func() { done <- runner.Run(ctx) }() select { case <-input.started: case <-time.After(200 * time.Millisecond): t.Fatal("runner never started reading input") } cancel() select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Fatalf("expected context cancellation, got %v", err) } case <-time.After(500 * time.Millisecond): t.Fatal("runner did not stop after context cancellation") } if !input.closed { t.Fatal("expected runner to close input reader on cancellation") } } type blockingReadCloser struct { started chan struct{} closed bool done chan struct{} } func newBlockingReadCloser() *blockingReadCloser { return &blockingReadCloser{ started: make(chan struct{}), done: make(chan struct{}), } } func (r *blockingReadCloser) Read(_ []byte) (int, error) { select { case <-r.started: default: close(r.started) } <-r.done return 0, io.EOF } func (r *blockingReadCloser) Close() error { if !r.closed { r.closed = true close(r.done) } return nil }