import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { NextcloudClient } from "../client.js"; import { ToolModule } from "./index.js"; import { ToolResponse } from "../types.js"; import { CalendarInfo, buildCalendarDiscoveryPropfindBody, buildCalendarEventsReportBody, dedupeEvents, formatICalDate, formatICalDateTimeUtc, getEventSortTimestamp, parseCalendarsFromPROPFIND, parseEventsFromCalDAV, parseISODateOnlyLocalMidnight, resolveCalendarSelectors, stripEventInternalFields, } from "../caldav.js"; import { format } from "date-fns"; import { escapeICalText, generateUUID as generateUID } from "../utils.js"; export const calendarToolModule: ToolModule = { definitions: [ { name: "list_calendars", description: "List available Nextcloud CalDAV calendars with href and supported components", inputSchema: { type: "object", properties: {} }, }, { name: "get_calendar_events", description: "Retrieve calendar events from Nextcloud. Can specify date range.", inputSchema: { type: "object", properties: { startDate: { type: "string", description: "Start date in ISO format (YYYY-MM-DD). Defaults to today.", }, endDate: { type: "string", description: "End date in ISO format (YYYY-MM-DD). Defaults to 30 days from start.", }, calendar: { type: "string", description: "Calendar selector by display name or href path (optional)", }, calendars: { type: "array", items: { type: "string" }, description: "List of calendar selectors by display name or href path (optional)", }, includeAllCalendars: { type: "boolean", description: "When true and no calendar selectors are provided, query all VEVENT calendars", default: true, }, limit: { type: "number", description: "Maximum number of events to return", default: 50, }, }, }, }, { name: "create_calendar_event", description: "Create a new calendar event in Nextcloud", inputSchema: { type: "object", properties: { summary: { type: "string", description: "Event title/summary" }, description: { type: "string", description: "Event description (optional)" }, allDay: { type: "boolean", description: "Create an all-day event. Use startDate/endDate when true.", default: false, }, startDateTime: { type: "string", description: "Start date/time in ISO format (YYYY-MM-DDTHH:mm:ss). Required for timed events.", }, endDateTime: { type: "string", description: "End date/time in ISO format (YYYY-MM-DDTHH:mm:ss). Required for timed events.", }, startDate: { type: "string", description: "All-day start date in YYYY-MM-DD (or YYYY/MM/DD). Required when allDay=true.", }, endDate: { type: "string", description: "All-day inclusive end date in YYYY-MM-DD (or YYYY/MM/DD). Optional; defaults to startDate.", }, reminderMinutesBefore: { type: "number", description: "Reminder trigger relative to event start, in minutes before start (optional).", }, reminderDateTime: { type: "string", description: "Absolute reminder timestamp in ISO format (optional, mutually exclusive with reminderMinutesBefore).", }, reminderDescription: { type: "string", description: "Reminder text shown to the user (optional).", }, reminderAction: { type: "string", description: "Alarm action type (optional, defaults to DISPLAY).", }, location: { type: "string", description: "Event location (optional)", }, }, required: ["summary"], }, }, ], async handler(name, args, client): Promise { switch (name) { case "list_calendars": return listCalendars(client); case "get_calendar_events": return getCalendarEvents(args, client); case "create_calendar_event": return createCalendarEvent(args, client); default: throw new Error(`Unknown calendar tool: ${name}`); } }, }; // --- Implementation --- async function listCalendars(client: NextcloudClient): Promise { const calendars = await discoverCalendars(client); return { content: [{ type: "text", text: JSON.stringify(calendars, null, 2) }], }; } async function getCalendarEvents(args: any, client: NextcloudClient): Promise { const startDate = args.startDate || format(new Date(), "yyyy-MM-dd"); const endDate = args.endDate || format(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), "yyyy-MM-dd"); const limit = args.limit || 50; const includeAllCalendars = args.includeAllCalendars !== false; const selectorList: string[] = []; if (typeof args.calendar === "string" && args.calendar.trim()) { selectorList.push(args.calendar.trim()); } if (Array.isArray(args.calendars)) { for (const selector of args.calendars) { if (typeof selector === "string" && selector.trim()) { selectorList.push(selector.trim()); } } } const startLocal = parseISODateOnlyLocalMidnight(startDate, "startDate"); const endLocal = parseISODateOnlyLocalMidnight(endDate, "endDate"); if (endLocal < startLocal) { throw new Error("endDate must be on or after startDate"); } try { const calendars = await discoverCalendars(client); const veventCalendars = calendars.filter((calendar) => calendar.components.includes("VEVENT") ); if (veventCalendars.length === 0) { return { content: [{ type: "text", text: JSON.stringify([], null, 2) }], }; } let targetCalendars: CalendarInfo[] = []; if (selectorList.length > 0) { targetCalendars = resolveCalendarSelectors(selectorList, calendars); } else if (includeAllCalendars) { targetCalendars = veventCalendars; } else { const personalCalendar = veventCalendars.find( (calendar) => calendar.name.toLowerCase() === "personal" ); targetCalendars = personalCalendar ? [personalCalendar] : [veventCalendars[0]]; } const requestBody = buildCalendarEventsReportBody(startLocal, endLocal); const mergedEvents: any[] = []; const requestErrors: string[] = []; for (const calendar of targetCalendars) { try { debugLog(client, `Querying calendar href: ${calendar.href}`); debugLog(client, `REPORT body for ${calendar.href}:\n${requestBody}`); const response = await client.report(calendar.href, requestBody, "1"); debugLog( client, `REPORT response for ${calendar.href}: status=200 body-preview=${response.slice(0, 500)}` ); const events = parseEventsFromCalDAV( response, calendar.href, (message) => debugLog(client, message) ).map((event) => ({ ...event, calendarName: calendar.name, calendarHref: calendar.href, })); mergedEvents.push(...events); } catch (error: any) { requestErrors.push(`${calendar.href}: ${error.message}`); debugLog(client, `Calendar query failed for ${calendar.href}: ${error.message}`); } } if (mergedEvents.length === 0 && requestErrors.length > 0) { throw new Error( `Failed to fetch calendar events from selected calendars: ${requestErrors.join("; ")}` ); } const deduped = dedupeEvents(mergedEvents); deduped.sort((a, b) => getEventSortTimestamp(a) - getEventSortTimestamp(b)); const events = deduped.slice(0, limit).map((event) => stripEventInternalFields(event)); return { content: [{ type: "text", text: JSON.stringify(events, null, 2) }], }; } catch (error: any) { throw new Error(`Failed to fetch calendar events: ${error.message}`); } } async function createCalendarEvent(args: any, client: NextcloudClient): Promise { const { summary, description, location, allDay, startDateTime, endDateTime, startDate, endDate, reminderMinutesBefore, reminderDateTime, reminderDescription, reminderAction, } = args; const uid = generateUID(); const isAllDay = allDay === true; let dtStartLine = ""; let dtEndLine = ""; if (isAllDay) { if (!startDate) { throw new Error("startDate is required when allDay=true"); } const start = parseISODateOnlyLocalMidnight(startDate, "startDate"); const endInclusive = parseISODateOnlyLocalMidnight(endDate || startDate, "endDate"); if (endInclusive < start) { throw new Error("endDate must be on or after startDate"); } const endExclusive = new Date(endInclusive.getTime()); endExclusive.setDate(endExclusive.getDate() + 1); dtStartLine = `DTSTART;VALUE=DATE:${formatICalDate(start)}`; dtEndLine = `DTEND;VALUE=DATE:${formatICalDate(endExclusive)}`; } else { if (!startDateTime || !endDateTime) { throw new Error("startDateTime and endDateTime are required for timed events"); } const start = new Date(startDateTime); const end = new Date(endDateTime); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { throw new Error("startDateTime/endDateTime must be valid ISO datetime values"); } if (end < start) { throw new Error("endDateTime must be on or after startDateTime"); } dtStartLine = `DTSTART:${formatICalDateTimeUtc(start)}`; dtEndLine = `DTEND:${formatICalDateTimeUtc(end)}`; } if (reminderMinutesBefore !== undefined && reminderDateTime !== undefined) { throw new Error("Use either reminderMinutesBefore or reminderDateTime, not both"); } let alarmBlock = ""; if (reminderMinutesBefore !== undefined || reminderDateTime !== undefined) { const action = typeof reminderAction === "string" && reminderAction.trim() ? reminderAction.trim().toUpperCase() : "DISPLAY"; const alarmDescription = escapeICalText(reminderDescription || "Reminder"); if (reminderMinutesBefore !== undefined) { const minutes = Number(reminderMinutesBefore); if (!Number.isFinite(minutes) || minutes < 0) { throw new Error("reminderMinutesBefore must be a number >= 0"); } const roundedMinutes = Math.floor(minutes); alarmBlock = `\nBEGIN:VALARM ACTION:${action} TRIGGER:-PT${roundedMinutes}M DESCRIPTION:${alarmDescription} END:VALARM`; } else if (reminderDateTime !== undefined) { const reminderAt = new Date(reminderDateTime); if (Number.isNaN(reminderAt.getTime())) { throw new Error("reminderDateTime must be a valid ISO datetime value"); } alarmBlock = `\nBEGIN:VALARM ACTION:${action} TRIGGER;VALUE=DATE-TIME:${formatICalDateTimeUtc(reminderAt)} DESCRIPTION:${alarmDescription} END:VALARM`; } } let vevent = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Nextcloud MCP Server//EN BEGIN:VEVENT UID:${uid} SUMMARY:${escapeICalText(summary)} ${dtStartLine} ${dtEndLine} CREATED:${formatICalDateTimeUtc(new Date())}`; if (description) { vevent += `\nDESCRIPTION:${escapeICalText(description)}`; } if (location) { vevent += `\nLOCATION:${escapeICalText(location)}`; } vevent += alarmBlock; vevent += `\nEND:VEVENT END:VCALENDAR`; try { const caldavPath = `/remote.php/dav/calendars/${client.username}/personal/${uid}.ics`; await client.put(caldavPath, vevent, { "Content-Type": "text/calendar", }); return { content: [ { type: "text", text: `Calendar event created successfully with UID: ${uid}`, }, ], }; } catch (error: any) { throw new Error(`Failed to create calendar event: ${error.message}`); } } // --- Helpers --- const debugEnabled = process.env.DEBUG_NEXTCLOUD_MCP === "1"; function debugLog(client: NextcloudClient, message: string): void { if (debugEnabled) { console.error(`[DEBUG_NEXTCLOUD_MCP] ${message}`); } } async function discoverCalendars(client: NextcloudClient): Promise { const calendarsRoot = `/remote.php/dav/calendars/${client.username}/`; const requestBody = buildCalendarDiscoveryPropfindBody(); debugLog(client, `PROPFIND calendars root: ${calendarsRoot}`); const response = await client.propfind(calendarsRoot, requestBody, "1"); debugLog( client, `PROPFIND response status=200 body-preview=${response.slice(0, 500)}` ); return parseCalendarsFromPROPFIND(response, calendarsRoot); }