From 6258bef3075f427d58018fa306c7904eff36325b Mon Sep 17 00:00:00 2001 From: Jonathan Godley Date: Thu, 2 Apr 2026 20:01:38 +1100 Subject: [PATCH] feat: add update and delete support for calendar events Add update_calendar_event and delete_calendar_event MCP tools with both JMAP and CalDAV implementations, following the existing dual-source fallback pattern. --- README.md | 10 +++- src/caldav-client.test.ts | 120 ++++++++++++++++++++++++++++++++++++++ src/caldav-client.ts | 73 ++++++++++++++++++++++- src/contacts-calendar.ts | 72 +++++++++++++++++++++++ src/index.ts | 92 ++++++++++++++++++++++++++++- 5 files changed, 361 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 07af8f7..cd251f3 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ A Model Context Protocol (MCP) server that provides access to the Fastmail API, ### Calendar Operations - List all calendars and calendar events - Get specific calendar events by ID -- Create new calendar events with participants and details +- Create, update, and delete calendar events ### Label vs Move Operations - **move_email/bulk_move**: Replaces ALL mailboxes for an email (folder behavior) @@ -132,7 +132,7 @@ You can install this server as a Desktop Extension for Claude Desktop using the 3. Use any of the tools (e.g. `get_recent_emails`). -## Available Tools (38 Total) +## Available Tools (40 Total) **🎯 Most Popular Tools:** - **check_function_availability**: Check what's available and get setup guidance @@ -219,6 +219,10 @@ You can install this server as a Desktop Extension for Claude Desktop using the - Parameters: `eventId` (required) - **create_calendar_event**: Create a new calendar event - Parameters: `calendarId` (required), `title` (required), `description` (optional), `start` (required, ISO 8601), `end` (required, ISO 8601), `location` (optional), `participants` (optional array) +- **update_calendar_event**: Update an existing calendar event (only provided fields are changed) + - Parameters: `eventId` (required), `title` (optional), `description` (optional), `start` (optional, ISO 8601), `end` (optional, ISO 8601), `location` (optional) +- **delete_calendar_event**: Delete a calendar event + - Parameters: `eventId` (required) ### Identity & Testing Tools @@ -262,7 +266,7 @@ However, Fastmail fully supports **CalDAV** for calendar access via `caldav.fast export FASTMAIL_CALDAV_PASSWORD="your-app-specific-password" ``` -When these variables are set, the calendar tools (`list_calendars`, `list_calendar_events`, `get_calendar_event`, `create_calendar_event`) will automatically fall back to CalDAV if JMAP calendars are not available. When these variables are not set, the server behaves exactly as before (JMAP only). +When these variables are set, the calendar tools (`list_calendars`, `list_calendar_events`, `get_calendar_event`, `create_calendar_event`, `update_calendar_event`, `delete_calendar_event`) will automatically fall back to CalDAV if JMAP calendars are not available. When these variables are not set, the server behaves exactly as before (JMAP only). ## Development diff --git a/src/caldav-client.test.ts b/src/caldav-client.test.ts index bee16bc..e6add84 100644 --- a/src/caldav-client.test.ts +++ b/src/caldav-client.test.ts @@ -336,3 +336,123 @@ describe('CalDAVCalendarClient.getCalendarEvents', () => { assert.equal(callArgs.timeRange, undefined); }); }); + +describe('CalDAVCalendarClient.updateCalendarEvent', () => { + function makeFullIcal(uid: string, summary: string, dtstart: string, dtend: string, description?: string, location?: string): string { + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//fastmail-mcp//CalDAV//EN', + 'BEGIN:VEVENT', + `UID:${uid}`, + `DTSTAMP:20260401T000000Z`, + `DTSTART:${dtstart}`, + `DTEND:${dtend}`, + `SUMMARY:${escapeICalText(summary)}`, + ]; + if (description) lines.push(`DESCRIPTION:${escapeICalText(description)}`); + if (location) lines.push(`LOCATION:${escapeICalText(location)}`); + lines.push('END:VEVENT', 'END:VCALENDAR'); + return lines.join('\r\n'); + } + + function createMockedClientWithUpdateDelete(calendarObjects: Array<{ data: string; url: string; etag?: string }>) { + const client = new CalDAVCalendarClient({ username: 'test', password: 'test' }); + const mockDAVClient = { + login: mock.fn(async () => {}), + fetchCalendars: mock.fn(async () => [ + { displayName: 'Personal', url: '/cal/personal/' }, + ]), + fetchCalendarObjects: mock.fn(async () => calendarObjects), + updateCalendarObject: mock.fn(async () => ({})), + deleteCalendarObject: mock.fn(async () => ({})), + }; + (client as any).client = mockDAVClient; + return { client, mockDAVClient }; + } + + it('updates only the title, preserving other fields', async () => { + const ical = makeFullIcal('evt1@fm', 'Original Title', '20260401T100000Z', '20260401T110000Z', 'My description', 'Room A'); + const objects = [{ data: ical, url: '/cal/evt1.ics', etag: '"etag1"' }]; + const { client, mockDAVClient } = createMockedClientWithUpdateDelete(objects); + + const result = await client.updateCalendarEvent('evt1@fm', { title: 'New Title' }); + + assert.equal(result, 'evt1@fm'); + assert.equal(mockDAVClient.updateCalendarObject.mock.calls.length, 1); + const updatedObj = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject; + assert.ok(updatedObj.data.includes('SUMMARY:New Title')); + assert.ok(updatedObj.data.includes('DESCRIPTION:My description')); + assert.ok(updatedObj.data.includes('LOCATION:Room A')); + assert.ok(updatedObj.data.includes('DTSTART:20260401T100000Z')); + assert.ok(updatedObj.data.includes('UID:evt1@fm')); + }); + + it('updates start and end times', async () => { + const ical = makeFullIcal('evt2@fm', 'Meeting', '20260401T100000Z', '20260401T110000Z'); + const objects = [{ data: ical, url: '/cal/evt2.ics' }]; + const { client, mockDAVClient } = createMockedClientWithUpdateDelete(objects); + + await client.updateCalendarEvent('evt2@fm', { + start: '2026-04-02T14:00:00Z', + end: '2026-04-02T15:00:00Z', + }); + + const updatedObj = mockDAVClient.updateCalendarObject.mock.calls[0].arguments[0].calendarObject; + assert.ok(updatedObj.data.includes('DTSTART:20260402T140000Z')); + assert.ok(updatedObj.data.includes('DTEND:20260402T150000Z')); + assert.ok(updatedObj.data.includes('SUMMARY:Meeting')); + }); + + it('throws when event not found', async () => { + const { client } = createMockedClientWithUpdateDelete([]); + await assert.rejects( + () => client.updateCalendarEvent('nonexistent@fm', { title: 'X' }), + /Calendar event not found: nonexistent@fm/ + ); + }); +}); + +describe('CalDAVCalendarClient.deleteCalendarEvent', () => { + function createMockedClientWithDelete(calendarObjects: Array<{ data: string; url: string; etag?: string }>) { + const client = new CalDAVCalendarClient({ username: 'test', password: 'test' }); + const mockDAVClient = { + login: mock.fn(async () => {}), + fetchCalendars: mock.fn(async () => [ + { displayName: 'Personal', url: '/cal/personal/' }, + ]), + fetchCalendarObjects: mock.fn(async () => calendarObjects), + deleteCalendarObject: mock.fn(async () => ({})), + }; + (client as any).client = mockDAVClient; + return { client, mockDAVClient }; + } + + it('deletes an event by UID', async () => { + const ical = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'UID:del1@fm', + 'DTSTART:20260401T100000Z', + 'SUMMARY:To Delete', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + const objects = [{ data: ical, url: '/cal/del1.ics', etag: '"etag1"' }]; + const { client, mockDAVClient } = createMockedClientWithDelete(objects); + + await client.deleteCalendarEvent('del1@fm'); + + assert.equal(mockDAVClient.deleteCalendarObject.mock.calls.length, 1); + const deletedObj = mockDAVClient.deleteCalendarObject.mock.calls[0].arguments[0].calendarObject; + assert.equal(deletedObj.url, '/cal/del1.ics'); + }); + + it('throws when event not found', async () => { + const { client } = createMockedClientWithDelete([]); + await assert.rejects( + () => client.deleteCalendarEvent('nonexistent@fm'), + /Calendar event not found: nonexistent@fm/ + ); + }); +}); diff --git a/src/caldav-client.ts b/src/caldav-client.ts index 144839f..b87e36c 100644 --- a/src/caldav-client.ts +++ b/src/caldav-client.ts @@ -217,7 +217,11 @@ export class CalDAVCalendarClient { return allEvents.slice(0, limit); } - async getCalendarEventById(eventId: string): Promise { + /** + * Find the raw DAVCalendarObject by UID or URL. + * Needed for update/delete which require the original object with url/etag. + */ + private async findCalendarObjectByUID(eventId: string): Promise { const client = await this.getClient(); if (!this.calendars) { @@ -230,7 +234,7 @@ export class CalDAVCalendarClient { const vevent = extractVEvent(obj.data || ''); const uid = parseICalValue(vevent, 'UID'); if (uid === eventId || obj.url === eventId) { - return parseCalendarObject(obj); + return obj; } } } @@ -238,6 +242,11 @@ export class CalDAVCalendarClient { return null; } + async getCalendarEventById(eventId: string): Promise { + const obj = await this.findCalendarObjectByUID(eventId); + return obj ? parseCalendarObject(obj) : null; + } + async createCalendarEvent(event: { calendarId: string; title: string; @@ -285,4 +294,64 @@ export class CalDAVCalendarClient { return uid; } + + async updateCalendarEvent(eventId: string, fields: { + title?: string; + description?: string; + start?: string; + end?: string; + location?: string; + }): Promise { + const client = await this.getClient(); + const obj = await this.findCalendarObjectByUID(eventId); + if (!obj) { + throw new Error(`Calendar event not found: ${eventId}`); + } + + const vevent = extractVEvent(obj.data || ''); + const existingUid = parseICalValue(vevent, 'UID') || eventId; + const existingStart = parseICalValue(vevent, 'DTSTART'); + const existingEnd = parseICalValue(vevent, 'DTEND'); + const existingSummary = parseICalValue(vevent, 'SUMMARY'); + const existingDescription = parseICalValue(vevent, 'DESCRIPTION'); + const existingLocation = parseICalValue(vevent, 'LOCATION'); + + const title = fields.title !== undefined ? escapeICalText(fields.title) : existingSummary || ''; + const description = fields.description !== undefined ? escapeICalText(fields.description) : existingDescription; + const location = fields.location !== undefined ? escapeICalText(fields.location) : existingLocation; + const startVal = fields.start !== undefined ? toICalUTC(fields.start) : existingStart || ''; + const endVal = fields.end !== undefined ? toICalUTC(fields.end) : existingEnd || ''; + + const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); + const ical = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//fastmail-mcp//CalDAV//EN', + 'BEGIN:VEVENT', + `UID:${existingUid}`, + `DTSTAMP:${now}`, + `DTSTART:${startVal}`, + `DTEND:${endVal}`, + foldICalLine(`SUMMARY:${title}`), + description ? foldICalLine(`DESCRIPTION:${description}`) : '', + location ? foldICalLine(`LOCATION:${location}`) : '', + 'END:VEVENT', + 'END:VCALENDAR', + ].filter(Boolean).join('\r\n'); + + obj.data = ical; + await client.updateCalendarObject({ calendarObject: obj }); + + return existingUid; + } + + async deleteCalendarEvent(eventId: string): Promise { + const client = await this.getClient(); + const obj = await this.findCalendarObjectByUID(eventId); + if (!obj) { + throw new Error(`Calendar event not found: ${eventId}`); + } + + await client.deleteCalendarObject({ calendarObject: obj }); + } } diff --git a/src/contacts-calendar.ts b/src/contacts-calendar.ts index 600e7f9..906c14f 100644 --- a/src/contacts-calendar.ts +++ b/src/contacts-calendar.ts @@ -259,4 +259,76 @@ export class ContactsCalendarClient extends JmapClient { throw new Error(`Calendar event creation not supported: ${error instanceof Error ? error.message : String(error)}. Try checking account permissions or enabling calendar API access in Fastmail settings.`); } } + + async updateCalendarEvent(eventId: string, fields: { + title?: string; + description?: string; + start?: string; + end?: string; + location?: string; + }): Promise { + const hasPermission = await this.checkCalendarsPermission(); + if (!hasPermission) { + throw new Error('Calendar access not available. This account may not have JMAP calendar permissions enabled. Please check your Fastmail account settings or contact support to enable calendar API access.'); + } + + const session = await this.getSession(); + + const updateFields: Record = {}; + if (fields.title !== undefined) updateFields.title = fields.title; + if (fields.description !== undefined) updateFields.description = fields.description; + if (fields.start !== undefined) updateFields.start = fields.start; + if (fields.end !== undefined) updateFields.end = fields.end; + if (fields.location !== undefined) updateFields.location = fields.location; + + const request: JmapRequest = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:calendars'], + methodCalls: [ + ['CalendarEvent/set', { + accountId: session.accountId, + update: { [eventId]: updateFields } + }, 'updateEvent'] + ] + }; + + try { + const response = await this.makeRequest(request); + const result = this.getMethodResult(response, 0); + if (result.notUpdated?.[eventId]) { + throw new Error(result.notUpdated[eventId].description || 'Update failed'); + } + return eventId; + } catch (error) { + throw new Error(`Calendar event update not supported: ${error instanceof Error ? error.message : String(error)}. Try checking account permissions or enabling calendar API access in Fastmail settings.`); + } + } + + async deleteCalendarEvent(eventId: string): Promise { + const hasPermission = await this.checkCalendarsPermission(); + if (!hasPermission) { + throw new Error('Calendar access not available. This account may not have JMAP calendar permissions enabled. Please check your Fastmail account settings or contact support to enable calendar API access.'); + } + + const session = await this.getSession(); + + const request: JmapRequest = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:calendars'], + methodCalls: [ + ['CalendarEvent/set', { + accountId: session.accountId, + destroy: [eventId] + }, 'deleteEvent'] + ] + }; + + try { + const response = await this.makeRequest(request); + const result = this.getMethodResult(response, 0); + if (result.notDestroyed?.[eventId]) { + throw new Error(result.notDestroyed[eventId].description || 'Delete failed'); + } + } catch (error) { + throw new Error(`Calendar event deletion not supported: ${error instanceof Error ? error.message : String(error)}. Try checking account permissions or enabling calendar API access in Fastmail settings.`); + } + } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 4e7bc04..5eb7010 100644 --- a/src/index.ts +++ b/src/index.ts @@ -562,6 +562,54 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ['calendarId', 'title', 'start', 'end'], }, }, + { + name: 'update_calendar_event', + description: 'Update an existing calendar event. Only provided fields are changed; omitted fields retain their current values.', + inputSchema: { + type: 'object', + properties: { + eventId: { + type: 'string', + description: 'ID of the event to update', + }, + title: { + type: 'string', + description: 'New event title (optional)', + }, + description: { + type: 'string', + description: 'New event description (optional)', + }, + start: { + type: 'string', + description: 'New start time in ISO 8601 format (optional)', + }, + end: { + type: 'string', + description: 'New end time in ISO 8601 format (optional)', + }, + location: { + type: 'string', + description: 'New event location (optional)', + }, + }, + required: ['eventId'], + }, + }, + { + name: 'delete_calendar_event', + description: 'Delete a calendar event by ID', + inputSchema: { + type: 'object', + properties: { + eventId: { + type: 'string', + description: 'ID of the event to delete', + }, + }, + required: ['eventId'], + }, + }, { name: 'list_identities', description: 'List sending identities (email addresses that can be used for sending)', @@ -1358,6 +1406,48 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } + case 'update_calendar_event': { + const { eventId, title, description, start, end, location } = args as any; + if (!eventId) { + throw new McpError(ErrorCode.InvalidParams, 'eventId is required'); + } + if (title === undefined && description === undefined && start === undefined && end === undefined && location === undefined) { + throw new McpError(ErrorCode.InvalidParams, 'At least one field to update must be provided (title, description, start, end, or location)'); + } + const fields = { title, description, start, end, location }; + try { + const contactsClient = initializeContactsCalendarClient(); + await contactsClient.updateCalendarEvent(eventId, fields); + return { content: [{ type: 'text', text: `Calendar event updated successfully. Event ID: ${eventId}` }] }; + } catch { + const davClient = initializeCalDAVClient(); + if (!davClient) { + throw new McpError(ErrorCode.InvalidRequest, 'JMAP calendars not available and CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD to use CalDAV.'); + } + await davClient.updateCalendarEvent(eventId, fields); + return { content: [{ type: 'text', text: `Calendar event updated via CalDAV. Event ID: ${eventId}` }] }; + } + } + + case 'delete_calendar_event': { + const { eventId } = args as any; + if (!eventId) { + throw new McpError(ErrorCode.InvalidParams, 'eventId is required'); + } + try { + const contactsClient = initializeContactsCalendarClient(); + await contactsClient.deleteCalendarEvent(eventId); + return { content: [{ type: 'text', text: `Calendar event deleted successfully. Event ID: ${eventId}` }] }; + } catch { + const davClient = initializeCalDAVClient(); + if (!davClient) { + throw new McpError(ErrorCode.InvalidRequest, 'JMAP calendars not available and CalDAV not configured. Set FASTMAIL_CALDAV_USERNAME and FASTMAIL_CALDAV_PASSWORD to use CalDAV.'); + } + await davClient.deleteCalendarEvent(eventId); + return { content: [{ type: 'text', text: `Calendar event deleted via CalDAV. Event ID: ${eventId}` }] }; + } + } + case 'list_identities': { const client = initializeClient(); const identities = await client.getIdentities(); @@ -1765,7 +1855,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }, calendar: { available: !!session.capabilities['urn:ietf:params:jmap:calendars'], - functions: ['list_calendars', 'list_calendar_events', 'get_calendar_event', 'create_calendar_event'], + functions: ['list_calendars', 'list_calendar_events', 'get_calendar_event', 'create_calendar_event', 'update_calendar_event', 'delete_calendar_event'], note: session.capabilities['urn:ietf:params:jmap:calendars'] ? 'Calendar is available' : 'Calendar access not available - may require enabling in Fastmail account settings',