diff --git a/src/contacts-calendar.ts b/src/contacts-calendar.ts index 600e7f9..8dda1a5 100644 --- a/src/contacts-calendar.ts +++ b/src/contacts-calendar.ts @@ -1,4 +1,4 @@ -import { JmapClient, JmapRequest } from './jmap-client.js'; +import { JmapClient, JmapRequest, QueryResult } from './jmap-client.js'; export class ContactsCalendarClient extends JmapClient { @@ -12,7 +12,7 @@ export class ContactsCalendarClient extends JmapClient { return !!session.capabilities['urn:ietf:params:jmap:calendars']; } - async getContacts(limit: number = 50): Promise { + async getContacts(limit: number = 50): Promise { // Check permissions first const hasPermission = await this.checkContactsPermission(); if (!hasPermission) { @@ -20,14 +20,15 @@ export class ContactsCalendarClient extends JmapClient { } const session = await this.getSession(); - + // Try CardDAV namespace first, then Fastmail specific const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:contacts'], methodCalls: [ ['ContactCard/query', { accountId: session.accountId, - limit + limit, + calculateTotal: true }, 'query'], ['ContactCard/get', { accountId: session.accountId, @@ -39,7 +40,7 @@ export class ContactsCalendarClient extends JmapClient { try { const response = await this.makeRequest(request); - return this.getListResult(response, 1); + return this.getQueryResult(response, 0, 1); } catch (error) { // Fallback: try to get contacts using AddressBook methods const fallbackRequest: JmapRequest = { @@ -50,10 +51,11 @@ export class ContactsCalendarClient extends JmapClient { }, 'addressbooks'] ] }; - + try { const fallbackResponse = await this.makeRequest(fallbackRequest); - return this.getListResult(fallbackResponse, 0); + const items = this.getListResult(fallbackResponse, 0); + return { items }; } catch (fallbackError) { throw new Error(`Contacts not supported or accessible: ${error instanceof Error ? error.message : String(error)}. Try checking account permissions or enabling contacts API access in Fastmail settings.`); } @@ -87,7 +89,7 @@ export class ContactsCalendarClient extends JmapClient { } } - async searchContacts(query: string, limit: number = 20): Promise { + async searchContacts(query: string, limit: number = 20): Promise { // Check permissions first const hasPermission = await this.checkContactsPermission(); if (!hasPermission) { @@ -95,14 +97,15 @@ export class ContactsCalendarClient extends JmapClient { } const session = await this.getSession(); - + const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:contacts'], methodCalls: [ ['ContactCard/query', { accountId: session.accountId, filter: { text: query }, - limit + limit, + calculateTotal: true }, 'query'], ['ContactCard/get', { accountId: session.accountId, @@ -114,7 +117,7 @@ export class ContactsCalendarClient extends JmapClient { try { const response = await this.makeRequest(request); - return this.getListResult(response, 1); + return this.getQueryResult(response, 0, 1); } catch (error) { throw new Error(`Contact search not supported: ${error instanceof Error ? error.message : String(error)}. Try checking account permissions or enabling contacts API access in Fastmail settings.`); } @@ -147,7 +150,7 @@ export class ContactsCalendarClient extends JmapClient { } } - async getCalendarEvents(calendarId?: string, limit: number = 50): Promise { + async getCalendarEvents(calendarId?: string, limit: number = 50): Promise { // Check permissions first const hasPermission = await this.checkCalendarsPermission(); if (!hasPermission) { @@ -155,9 +158,9 @@ export class ContactsCalendarClient extends JmapClient { } const session = await this.getSession(); - + const filter = calendarId ? { inCalendar: calendarId } : {}; - + const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:calendars'], methodCalls: [ @@ -165,7 +168,8 @@ export class ContactsCalendarClient extends JmapClient { accountId: session.accountId, filter, sort: [{ property: 'start', isAscending: true }], - limit + limit, + calculateTotal: true }, 'query'], ['CalendarEvent/get', { accountId: session.accountId, @@ -177,7 +181,7 @@ export class ContactsCalendarClient extends JmapClient { try { const response = await this.makeRequest(request); - return this.getListResult(response, 1); + return this.getQueryResult(response, 0, 1); } catch (error) { throw new Error(`Calendar events access not supported: ${error instanceof Error ? error.message : String(error)}. Try checking account permissions or enabling calendar API access in Fastmail settings.`); } diff --git a/src/index.ts b/src/index.ts index 4e7bc04..4a09f14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { McpError, } from '@modelcontextprotocol/sdk/types.js'; import { FastmailAuth, FastmailConfig } from './auth.js'; -import { JmapClient } from './jmap-client.js'; +import { JmapClient, QueryResult } from './jmap-client.js'; import { ContactsCalendarClient } from './contacts-calendar.js'; import { CalDAVCalendarClient } from './caldav-client.js'; @@ -978,6 +978,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; + function formatQueryResult(result: QueryResult): string { + const { items, total } = result; + const summary = total != null && total > items.length + ? `Showing ${items.length} of ${total} results.` + : total != null + ? `${total} results.` + : `${items.length} results.`; + return `${summary}\n${JSON.stringify(items, null, 2)}`; + } + try { const client = initializeClient(); @@ -997,13 +1007,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { case 'list_emails': { const { mailboxId, limit, ascending } = args as any; - const validLimit = Math.min(Math.max(Number(limit) || 20, 1), 50); - const emails = await client.getEmails(mailboxId, validLimit, !!ascending); + const validLimit = Math.min(Math.max(Number(limit) || 20, 1), 100); + const result = await client.getEmails(mailboxId, validLimit, !!ascending); return { content: [ { type: 'text', - text: JSON.stringify(emails, null, 2), + text: formatQueryResult(result), }, ], }; @@ -1225,12 +1235,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (!query) { throw new McpError(ErrorCode.InvalidParams, 'query is required'); } - const emails = await client.searchEmails(query, limit, !!ascending); + const validLimit = Math.min(Math.max(Number(limit) || 20, 1), 100); + const result = await client.searchEmails(query, validLimit, !!ascending); return { content: [ { type: 'text', - text: JSON.stringify(emails, null, 2), + text: formatQueryResult(result), }, ], }; @@ -1239,12 +1250,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { case 'list_contacts': { const { limit = 50 } = args as any; const contactsClient = initializeContactsCalendarClient(); - const contacts = await contactsClient.getContacts(limit); + const result = await contactsClient.getContacts(limit); return { content: [ { type: 'text', - text: JSON.stringify(contacts, null, 2), + text: formatQueryResult(result), }, ], }; @@ -1273,12 +1284,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw new McpError(ErrorCode.InvalidParams, 'query is required'); } const contactsClient = initializeContactsCalendarClient(); - const contacts = await contactsClient.searchContacts(query, limit); + const result = await contactsClient.searchContacts(query, limit); return { content: [ { type: 'text', - text: JSON.stringify(contacts, null, 2), + text: formatQueryResult(result), }, ], }; @@ -1304,8 +1315,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const { calendarId, limit = 50, startDate, endDate } = args as any; try { const contactsClient = initializeContactsCalendarClient(); - const events = await contactsClient.getCalendarEvents(calendarId, limit); - return { content: [{ type: 'text', text: JSON.stringify(events, null, 2) }] }; + const result = await contactsClient.getCalendarEvents(calendarId, limit); + return { content: [{ type: 'text', text: formatQueryResult(result) }] }; } catch { const davClient = initializeCalDAVClient(); if (!davClient) { @@ -1375,12 +1386,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { case 'get_recent_emails': { const { limit = 10, mailboxName = 'inbox', ascending } = args as any; const client = initializeClient(); - const emails = await client.getRecentEmails(limit, mailboxName, !!ascending); + const result = await client.getRecentEmails(limit, mailboxName, !!ascending); return { content: [ { type: 'text', - text: JSON.stringify(emails, null, 2), + text: formatQueryResult(result), }, ], }; @@ -1555,14 +1566,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { case 'advanced_search': { const { query, from, to, subject, hasAttachment, isUnread, isPinned, mailboxId, after, before, limit, ascending } = args as any; const client = initializeClient(); - const emails = await client.advancedSearch({ - query, from, to, subject, hasAttachment, isUnread, isPinned, mailboxId, after, before, limit, ascending + const validLimit = Math.min(Math.max(Number(limit) || 50, 1), 100); + const result = await client.advancedSearch({ + query, from, to, subject, hasAttachment, isUnread, isPinned, mailboxId, after, before, limit: validLimit, ascending }); return { content: [ { type: 'text', - text: JSON.stringify(emails, null, 2), + text: formatQueryResult(result), }, ], }; @@ -1798,8 +1810,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // Get some recent emails to test with const testLimit = Math.min(Math.max(limit, 1), 10); - const emails = await client.getRecentEmails(testLimit, 'inbox'); - + const { items: emails } = await client.getRecentEmails(testLimit, 'inbox'); + if (emails.length === 0) { return { content: [ diff --git a/src/jmap-client-extra.test.ts b/src/jmap-client-extra.test.ts index ed65152..c1c38ba 100644 --- a/src/jmap-client-extra.test.ts +++ b/src/jmap-client-extra.test.ts @@ -87,9 +87,9 @@ describe('getRecentEmails', () => { ], }); - const emails = await client.getRecentEmails(10, 'inbox'); - assert.equal(emails.length, 2); - assert.equal(emails[0].subject, 'First'); + const result = await client.getRecentEmails(10, 'inbox'); + assert.equal(result.items.length, 2); + assert.equal(result.items[0].subject, 'First'); }); it('throws when mailbox is not found', async () => { @@ -113,8 +113,8 @@ describe('getRecentEmails', () => { ], }); - const emails = await client.getRecentEmails(5, 'inbox'); - assert.deepEqual(emails, []); + const result = await client.getRecentEmails(5, 'inbox'); + assert.deepEqual(result.items, []); }); }); @@ -135,8 +135,8 @@ describe('getEmails', () => { ], })); - const emails = await client.getEmails('mb-inbox', 5); - assert.equal(emails.length, 1); + const result = await client.getEmails('mb-inbox', 5); + assert.equal(result.items.length, 1); const filter = makeReq.mock.calls[0].arguments[0].methodCalls[0][1].filter; assert.equal(filter.inMailbox, 'mb-inbox'); @@ -150,8 +150,8 @@ describe('getEmails', () => { ], })); - const emails = await client.getEmails(undefined, 10); - assert.equal(emails.length, 1); + const result = await client.getEmails(undefined, 10); + assert.equal(result.items.length, 1); const filter = makeReq.mock.calls[0].arguments[0].methodCalls[0][1].filter; assert.deepEqual(filter, {}); diff --git a/src/jmap-client.test.ts b/src/jmap-client.test.ts index 899fbcd..67954ee 100644 --- a/src/jmap-client.test.ts +++ b/src/jmap-client.test.ts @@ -504,8 +504,8 @@ describe('searchEmails', () => { ], }); const results = await client.searchEmails('test', 10); - assert.equal(results.length, 1); - assert.equal(results[0].subject, 'Test'); + assert.equal(results.items.length, 1); + assert.equal(results.items[0].subject, 'Test'); }); it('returns empty array when no results', async () => { @@ -516,7 +516,7 @@ describe('searchEmails', () => { ], }); const results = await client.searchEmails('nonexistent'); - assert.deepEqual(results, []); + assert.deepEqual(results.items, []); }); it('throws on JMAP error in query', async () => { diff --git a/src/jmap-client.ts b/src/jmap-client.ts index cb5aa62..5c6f440 100644 --- a/src/jmap-client.ts +++ b/src/jmap-client.ts @@ -21,6 +21,11 @@ export interface JmapResponse { sessionState: string; } +export interface QueryResult { + items: T[]; + total?: number; +} + export class JmapClient { private auth: FastmailAuth; private session: JmapSession | null = null; @@ -57,6 +62,17 @@ export class JmapClient { return result?.list || []; } + /** + * Build a QueryResult from a query + get pair. + * queryIndex is the /query response; listIndex is the /get response. + */ + protected getQueryResult(response: JmapResponse, queryIndex: number, listIndex: number): QueryResult { + const queryResult = this.getMethodResult(response, queryIndex); + const items = this.getListResult(response, listIndex); + const total = queryResult?.total; + return total != null ? { items, total } : { items }; + } + async getSession(): Promise { if (this.session) { return this.session; @@ -135,7 +151,7 @@ export class JmapClient { return this.getListResult(response, 0); } - async getEmails(mailboxId?: string, limit: number = 20, ascending: boolean = false): Promise { + async getEmails(mailboxId?: string, limit: number = 20, ascending: boolean = false): Promise { const session = await this.getSession(); const filter = mailboxId ? { inMailbox: mailboxId } : {}; @@ -147,7 +163,8 @@ export class JmapClient { accountId: session.accountId, filter, sort: [{ property: 'receivedAt', isAscending: ascending }], - limit + limit, + calculateTotal: true }, 'query'], ['Email/get', { accountId: session.accountId, @@ -158,7 +175,7 @@ export class JmapClient { }; const response = await this.makeRequest(request); - return this.getListResult(response, 1); + return this.getQueryResult(response, 0, 1); } async getEmailById(id: string): Promise { @@ -698,7 +715,7 @@ export class JmapClient { return submissionId; } - async getRecentEmails(limit: number = 10, mailboxName: string = 'inbox', ascending: boolean = false): Promise { + async getRecentEmails(limit: number = 10, mailboxName: string = 'inbox', ascending: boolean = false): Promise { const session = await this.getSession(); // Find the specified mailbox (default to inbox) @@ -719,7 +736,8 @@ export class JmapClient { accountId: session.accountId, filter: { inMailbox: targetMailbox.id }, sort: [{ property: 'receivedAt', isAscending: ascending }], - limit: Math.min(limit, 50) + limit: Math.min(limit, 50), + calculateTotal: true }, 'query'], ['Email/get', { accountId: session.accountId, @@ -730,7 +748,7 @@ export class JmapClient { }; const response = await this.makeRequest(request); - return this.getListResult(response, 1); + return this.getQueryResult(response, 0, 1); } async markEmailRead(emailId: string, read: boolean = true): Promise { @@ -1117,12 +1135,12 @@ export class JmapClient { before?: string; limit?: number; ascending?: boolean; - }): Promise { + }): Promise { const session = await this.getSession(); - + // Build JMAP filter object const filter: any = {}; - + if (filters.query) filter.text = filters.query; if (filters.from) filter.from = filters.from; if (filters.to) filter.to = filters.to; @@ -1155,7 +1173,8 @@ export class JmapClient { accountId: session.accountId, filter: finalFilter, sort: [{ property: 'receivedAt', isAscending: filters.ascending ?? false }], - limit: Math.min(filters.limit || 50, 100) + limit: Math.min(filters.limit || 50, 100), + calculateTotal: true }, 'query'], ['Email/get', { accountId: session.accountId, @@ -1166,10 +1185,10 @@ export class JmapClient { }; const response = await this.makeRequest(request); - return this.getListResult(response, 1); + return this.getQueryResult(response, 0, 1); } - async searchEmails(query: string, limit: number = 20, ascending: boolean = false): Promise { + async searchEmails(query: string, limit: number = 20, ascending: boolean = false): Promise { const session = await this.getSession(); const request: JmapRequest = { @@ -1179,7 +1198,8 @@ export class JmapClient { accountId: session.accountId, filter: { text: query }, sort: [{ property: 'receivedAt', isAscending: ascending }], - limit + limit, + calculateTotal: true }, 'query'], ['Email/get', { accountId: session.accountId, @@ -1190,7 +1210,7 @@ export class JmapClient { }; const response = await this.makeRequest(request); - return this.getListResult(response, 1); + return this.getQueryResult(response, 0, 1); } async getThread(threadId: string): Promise {