diff --git a/QUICKSTART.md b/QUICKSTART.md index fca3079..925307c 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -109,7 +109,7 @@ Before asking Claude to use Nextcloud: ### "Calendar not found" or "Tasks not found" - Check that you have at least one calendar created - Verify the Tasks app is installed and has a task list -- See customization section in README for different calendar names +- Use `list_calendars` to discover exact calendar names/hrefs ### Claude doesn't show Nextcloud tools - Verify Claude Desktop config file syntax (use a JSON validator) @@ -120,10 +120,54 @@ Before asking Claude to use Nextcloud: ## 🎯 Next Steps Once working: -1. Customize calendar/task list names in `src/index.ts` if needed +1. Use `list_calendars`, then call `get_calendar_events` with `calendar` or `calendars` when you want specific calendars 2. Add more tools as needed for your workflow 3. Check out the full README.md for advanced features +## 🔍 CalDAV Quick Debug (curl) + +List calendars: +```bash +curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \ + -X PROPFIND \ + -H "Depth: 1" \ + -H "Accept: application/xml" \ + -H "Content-Type: application/xml; charset=utf-8" \ + --data ' + + + + + + +' \ + "$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/" +``` + +Query events: +```bash +curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \ + -X REPORT \ + -H "Depth: 1" \ + -H "Accept: application/xml" \ + -H "Content-Type: application/xml; charset=utf-8" \ + --data ' + + + + + + + + + + + + +' \ + "$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/personal/" +``` + ## 📚 Example Use Cases **Task Management**: diff --git a/README.md b/README.md index 2866f18..2d96887 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ A Model Context Protocol (MCP) server that integrates with Nextcloud to provide ### Calendar - ✅ Get calendar events with date range filtering +- ✅ Query one, many, or all VEVENT calendars automatically +- ✅ Discover available calendars and supported components - ✅ Create new calendar events with details and location ### Notes @@ -125,9 +127,17 @@ Once connected, Claude can use these tools: - `update_task` - Update existing task (mark complete, change details) ### Calendar -- `get_calendar_events` - Get events in date range +- `list_calendars` - List available calendars (`name`, `href`, `components`) +- `get_calendar_events` - Get events in date range across selected calendars - `create_calendar_event` - Create new event with details +`get_calendar_events` supports: +- `startDate` / `endDate` (YYYY-MM-DD) +- `calendar` (single calendar name or href) +- `calendars` (array of names/hrefs) +- `includeAllCalendars` (default `true`, queries all VEVENT calendars when no selectors are provided) +- `limit` + ### Notes - `get_notes` - List all notes - `create_note` - Create new note with markdown @@ -158,9 +168,52 @@ Once the MCP server is connected, you can ask Claude: - Check that required apps (Tasks, Calendar, Notes) are installed ### CalDAV Issues -- Verify calendar/task list names match your Nextcloud setup -- Default calendar name is "personal" - adjust in code if needed -- Default task list name is "tasks" - adjust in code if needed +- Use `list_calendars` to discover calendar names/hrefs from your server +- Set `DEBUG_NEXTCLOUD_MCP=1` to log CalDAV requests and parsing details +- Default task list name is still `tasks` for task operations + +### Debugging with curl +List calendars with PROPFIND: +```bash +curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \ + -X PROPFIND \ + -H "Depth: 1" \ + -H "Accept: application/xml" \ + -H "Content-Type: application/xml; charset=utf-8" \ + --data ' + + + + + + +' \ + "$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/" +``` + +Query events with REPORT: +```bash +curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \ + -X REPORT \ + -H "Depth: 1" \ + -H "Accept: application/xml" \ + -H "Content-Type: application/xml; charset=utf-8" \ + --data ' + + + + + + + + + + + + +' \ + "$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/personal/" +``` ### Email Issues - Ensure Nextcloud Mail app is installed and configured @@ -187,15 +240,8 @@ Check the MCP server logs in Claude Desktop: ## Customization -### Changing Calendar Names -Edit `src/index.ts` and update the calendar paths: -```typescript -// Default personal calendar -const caldavPath = `/remote.php/dav/calendars/${this.config.username}/personal/`; - -// For a different calendar, change "personal" to your calendar name -const caldavPath = `/remote.php/dav/calendars/${this.config.username}/work/`; -``` +### Calendar Selection +Use `list_calendars` and pass `calendar` / `calendars` to `get_calendar_events` to target specific calendars by name or href. ### Changing Task List Names ```typescript diff --git a/package-lock.json b/package-lock.json index b2623e3..8858ce1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@modelcontextprotocol/sdk": "^1.20.2", "axios": "^1.12.2", "date-fns": "^4.1.0", + "ical.js": "^2.2.1", "zod": "^3.25.76" }, "bin": { @@ -882,6 +883,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -1188,6 +1190,12 @@ "node": ">= 0.8" } }, + "node_modules/ical.js": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz", + "integrity": "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==", + "license": "MPL-2.0" + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -1775,6 +1783,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 7b30d3d..e22a226 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,21 @@ "dev": "tsx src/index.ts", "test-connection": "node test-connection.js" }, - "keywords": ["mcp", "nextcloud", "calendar", "tasks", "notes", "email"], + "keywords": [ + "mcp", + "nextcloud", + "calendar", + "tasks", + "notes", + "email" + ], "author": "", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.20.2", "axios": "^1.12.2", "date-fns": "^4.1.0", + "ical.js": "^2.2.1", "zod": "^3.25.76" }, "devDependencies": { diff --git a/src/caldav.ts b/src/caldav.ts new file mode 100644 index 0000000..e382ded --- /dev/null +++ b/src/caldav.ts @@ -0,0 +1,524 @@ +import { format } from "date-fns"; +import * as ICAL from "ical.js"; + +export interface CalendarInfo { + name: string; + href: string; + components: string[]; +} + +export type DebugLogger = (message: string) => void; + +export function getCalDAVXmlHeaders(depth: string = "1"): Record { + return { + Accept: "application/xml", + "Content-Type": "application/xml; charset=utf-8", + Depth: depth, + }; +} + +export function buildTasksReportBody(): string { + return ` + + + + + + + + + + +`; +} + +export function buildCalendarEventsReportBody(start: Date, end: Date): string { + return ` + + + + + + + + + + + + + + +`; +} + +export function buildCalendarDiscoveryPropfindBody(): string { + return ` + + + + + + +`; +} + +export function parseTasksFromCalDAV( + xmlData: string, + status: string, + limit: number +): any[] { + const tasks: any[] = []; + for (const todoData of extractCalendarDataBlocks(xmlData)) { + if (tasks.length >= limit) { + break; + } + + const task = parseVTODO(todoData); + if (!task) { + continue; + } + + if (status === "all") { + tasks.push(task); + continue; + } + if (status === "completed" && task.status === "COMPLETED") { + tasks.push(task); + continue; + } + if (status === "open" && task.status !== "COMPLETED") { + tasks.push(task); + } + } + + return tasks; +} + +export function parseEventsFromCalDAV( + xmlData: string, + calendarHref: string, + debugLog?: DebugLogger +): any[] { + const events: any[] = []; + const blocks = extractCalendarDataBlocks(xmlData); + debugLog?.(`Extracted ${blocks.length} calendar-data blocks from ${calendarHref}`); + + for (const calendarData of blocks) { + try { + const jcalData = ICAL.parse(calendarData); + const comp = new ICAL.Component(jcalData); + const vevents = comp.getAllSubcomponents("vevent"); + + for (const vevent of vevents) { + const event: any = {}; + event.uid = vevent.getFirstPropertyValue("uid"); + + if (vevent.hasProperty("summary")) { + event.summary = vevent.getFirstPropertyValue("summary"); + } + if (vevent.hasProperty("description")) { + event.description = vevent.getFirstPropertyValue("description"); + } + if (vevent.hasProperty("location")) { + event.location = vevent.getFirstPropertyValue("location"); + } + if (vevent.hasProperty("status")) { + event.status = vevent.getFirstPropertyValue("status"); + } + + if (vevent.hasProperty("dtstart")) { + const startProp = vevent.getFirstProperty("dtstart"); + event.startRaw = startProp.getFirstValue().toString(); + event.start = event.startRaw; + } + if (vevent.hasProperty("dtend")) { + const endProp = vevent.getFirstProperty("dtend"); + event.end = endProp.getFirstValue().toString(); + } + if (vevent.hasProperty("rrule")) { + const rrule = vevent.getFirstPropertyValue("rrule"); + event.rrule = rrule.toString(); + } + + const alarms = vevent.getAllSubcomponents("valarm"); + if (alarms && alarms.length > 0) { + event.alarms = alarms.map((alarm: any) => ({ + action: alarm.getFirstPropertyValue("action"), + trigger: alarm.getFirstPropertyValue("trigger") ? alarm.getFirstProperty("trigger").getFirstValue().toString() : null, + description: alarm.getFirstPropertyValue("description") + })); + } + + if (event.uid) { + events.push(event); + } + } + } catch (e) { + debugLog?.(`Failed to parse iCal block: ${e}`); + } + } + + debugLog?.(`Parsed ${events.length} events from ${calendarHref}`); + return events; +} + +export function parseCalendarsFromPROPFIND( + xmlData: string, + calendarsRoot: string +): CalendarInfo[] { + const calendarsByHref = new Map(); + const responseMatches = xmlData.matchAll( + /<(?:\w+:)?response\b[\s\S]*?<\/(?:\w+:)?response>/g + ); + + for (const match of responseMatches) { + const responseBlock = match[0]; + const hrefMatch = responseBlock.match( + /<(?:\w+:)?href[^>]*>([\s\S]*?)<\/(?:\w+:)?href>/ + ); + if (!hrefMatch) { + continue; + } + + const rawHref = decodeXmlText(hrefMatch[1].trim()); + const href = normalizeCalendarHref(rawHref); + if (!href.startsWith(calendarsRoot) || href === calendarsRoot) { + continue; + } + + const isCalendar = /<(?:\w+:)?calendar(?:\s*\/>|>)/i.test(responseBlock); + if (!isCalendar) { + continue; + } + + const componentMatches = responseBlock.matchAll( + /<(?:\w+:)?comp\b[^>]*\bname="([^"]+)"/gi + ); + const detectedComponents = Array.from( + new Set( + Array.from(componentMatches, (componentMatch) => + componentMatch[1].toUpperCase() + ) + ) + ); + const components = + detectedComponents.length > 0 ? detectedComponents : ["VEVENT", "VTODO"]; + + const displayNameMatch = responseBlock.match( + /<(?:\w+:)?displayname[^>]*>([\s\S]*?)<\/(?:\w+:)?displayname>/ + ); + const displayName = displayNameMatch + ? decodeXmlText(displayNameMatch[1].trim()) + : ""; + + const fallbackName = href + .replace(/\/$/, "") + .split("/") + .filter(Boolean) + .pop(); + const name = displayName || fallbackName || "Unnamed"; + + calendarsByHref.set(href, { name, href, components }); + } + + return Array.from(calendarsByHref.values()); +} + +export function resolveCalendarSelectors( + selectors: string[], + calendars: CalendarInfo[] +): CalendarInfo[] { + const selected = new Map(); + + for (const selector of selectors) { + const normalizedSelectorHref = normalizeCalendarHref(selector); + const byHref = calendars.find( + (calendar) => normalizeCalendarHref(calendar.href) === normalizedSelectorHref + ); + const byName = calendars.find( + (calendar) => calendar.name.toLowerCase() === selector.toLowerCase() + ); + const resolved = byHref || byName; + if (!resolved) { + throw new Error(`Calendar not found: ${selector}`); + } + selected.set(resolved.href, resolved); + } + + return Array.from(selected.values()); +} + +export function dedupeEvents(events: any[]): any[] { + const unique = new Map(); + + for (const event of events) { + const uid = typeof event.uid === "string" ? event.uid : ""; + if (!uid) { + continue; + } + const dtstart = + typeof event.startRaw === "string" && event.startRaw.trim() + ? event.startRaw.trim() + : ""; + const key = dtstart ? `${uid}|${dtstart}` : uid; + if (!unique.has(key)) { + unique.set(key, event); + } + } + + return Array.from(unique.values()); +} + +export function getEventSortTimestamp(event: any): number { + const raw = typeof event.startRaw === "string" ? event.startRaw : ""; + if (!raw) { + return Number.POSITIVE_INFINITY; + } + const parsed = parseICalToDate(raw); + return parsed ? parsed.getTime() : Number.POSITIVE_INFINITY; +} + +export function stripEventInternalFields(event: any): any { + const { startRaw, ...rest } = event; + return rest; +} + +export function parseISODateOnlyLocalMidnight( + dateStr: string, + fieldName: string +): Date { + const match = dateStr.match(/^(\d{4})[-/](\d{2})[-/](\d{2})$/); + if (!match) { + throw new Error(`${fieldName} must be in YYYY-MM-DD or YYYY/MM/DD format`); + } + + const year = Number(match[1]); + const month = Number(match[2]); + let day = Number(match[3]); + + if (month < 1 || month > 12) { + throw new Error(`${fieldName} month must be between 01 and 12`); + } + + const maxDay = new Date(year, month, 0).getDate(); + if (day < 1) { + day = 1; + } else if (day > maxDay) { + day = maxDay; + } + + const localDate = new Date(year, month - 1, day, 0, 0, 0, 0); + + return localDate; +} + +export function formatICalDateTimeUtc(date: Date): string { + const utcShifted = new Date(date.getTime() + date.getTimezoneOffset() * 60 * 1000); + return format(utcShifted, "yyyyMMdd'T'HHmmss'Z'"); +} + +export function formatICalDate(date: Date): string { + return format(date, "yyyyMMdd"); +} + +export function parseICalDate(icalDate: string): string { + if (icalDate.includes("T")) { + const year = icalDate.substring(0, 4); + const month = icalDate.substring(4, 6); + const day = icalDate.substring(6, 8); + const hour = icalDate.substring(9, 11); + const minute = icalDate.substring(11, 13); + return `${year}-${month}-${day} ${hour}:${minute}`; + } + + const year = icalDate.substring(0, 4); + const month = icalDate.substring(4, 6); + const day = icalDate.substring(6, 8); + return `${year}-${month}-${day}`; +} + +function extractCalendarDataBlocks(xmlData: string): string[] { + return Array.from( + xmlData.matchAll( + /<(?:\w+:)?calendar-data[^>]*>([\s\S]*?)<\/(?:\w+:)?calendar-data>/g + ), + (match) => decodeXmlText(match[1]) + ); +} + +function parseVTODO(todoData: string): any | null { + try { + const jcalData = ICAL.parse(todoData); + const comp = new ICAL.Component(jcalData); + const vtodo = comp.getFirstSubcomponent("vtodo"); + if (!vtodo) return null; + + const task: any = {}; + task.uid = vtodo.getFirstPropertyValue("uid"); + + if (vtodo.hasProperty("summary")) { + task.summary = vtodo.getFirstPropertyValue("summary"); + } + if (vtodo.hasProperty("status")) { + task.status = vtodo.getFirstPropertyValue("status"); + } + if (vtodo.hasProperty("description")) { + task.description = vtodo.getFirstPropertyValue("description"); + } + if (vtodo.hasProperty("percent-complete")) { + task.percentComplete = vtodo.getFirstPropertyValue("percent-complete"); + } + if (vtodo.hasProperty("priority")) { + task.priority = vtodo.getFirstPropertyValue("priority"); + } + if (vtodo.hasProperty("due")) { + const dueProp = vtodo.getFirstProperty("due"); + if (dueProp) { + task.due = dueProp.getFirstValue().toString(); + } + } + if (vtodo.hasProperty("created")) { + const createdProp = vtodo.getFirstProperty("created"); + if (createdProp) { + task.created = createdProp.getFirstValue().toString(); + } + } + if (vtodo.hasProperty("last-modified")) { + const lmProp = vtodo.getFirstProperty("last-modified"); + if (lmProp) { + task.lastModified = lmProp.getFirstValue().toString(); + } + } + + return task.uid ? task : null; + } catch (error) { + return null; + } +} + +function parseVEVENT(eventData: string): any | null { + try { + const jcalData = ICAL.parse(eventData); + const comp = new ICAL.Component(jcalData); + const vevents = comp.getAllSubcomponents("vevent"); + + // We expect individual events here, handled iteration in the caller if needed + // However, if the calendarData block has multiple events, ICAL parsing the whole + // object will find them. We'll return the first one here that has a UID. + const vevent = comp.getFirstSubcomponent("vevent"); + if (!vevent) return null; + + const event: any = {}; + event.uid = vevent.getFirstPropertyValue("uid"); + + if (vevent.hasProperty("summary")) { + event.summary = vevent.getFirstPropertyValue("summary"); + } + if (vevent.hasProperty("description")) { + event.description = vevent.getFirstPropertyValue("description"); + } + if (vevent.hasProperty("location")) { + event.location = vevent.getFirstPropertyValue("location"); + } + if (vevent.hasProperty("status")) { + event.status = vevent.getFirstPropertyValue("status"); + } + + if (vevent.hasProperty("dtstart")) { + const startProp = vevent.getFirstProperty("dtstart"); + event.startRaw = startProp.getFirstValue().toString(); + event.start = event.startRaw; + } + if (vevent.hasProperty("dtend")) { + const endProp = vevent.getFirstProperty("dtend"); + event.end = endProp.getFirstValue().toString(); + } + if (vevent.hasProperty("rrule")) { + const rrule = vevent.getFirstPropertyValue("rrule"); + event.rrule = rrule.toString(); + } + + const alarms = vevent.getAllSubcomponents("valarm"); + if (alarms && alarms.length > 0) { + event.alarms = alarms.map((alarm: any) => ({ + action: alarm.getFirstPropertyValue("action"), + trigger: alarm.getFirstPropertyValue("trigger") ? alarm.getFirstProperty("trigger").getFirstValue().toString() : null, + description: alarm.getFirstPropertyValue("description") + })); + } + + return event.uid ? event : null; + } catch (error) { + return null; + } +} + +function extractVEventBlocks(calendarData: string): string[] { + // ICAL.js does not need string splitting, it parses the whole VCALENDAR. + // We'll leave this to split generic strings if needed by the old logic, + // but better yet, let's just parse the full calendarData string directly in the caller. + return Array.from( + calendarData.matchAll(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g), + (match) => `BEGIN:VCALENDAR\nVERSION:2.0\n${match[0]}\nEND:VCALENDAR` + ); +} + +function unfoldICalLines(data: string): string[] { + // Normalize CR artifacts and unfold folded iCalendar lines. + const normalized = data.replace(/\r/g, "").replace(/\n[ \t]/g, ""); + return normalized.split(/\n/); +} + +function normalizeCalendarHref(href: string): string { + let normalized = href.trim(); + if (!normalized) { + return normalized; + } + + if (/^https?:\/\//i.test(normalized)) { + try { + normalized = new URL(normalized).pathname; + } catch { + // Keep original if URL parsing fails. + } + } + + if (!normalized.startsWith("/")) { + normalized = `/${normalized}`; + } + if (!normalized.endsWith("/")) { + normalized = `${normalized}/`; + } + + return normalized; +} + +function decodeXmlText(value: string): string { + return value + .replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => + String.fromCodePoint(parseInt(hex, 16)) + ) + .replace(/&#([0-9]+);/g, (_m, dec) => + String.fromCodePoint(parseInt(dec, 10)) + ) + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +function parseICalToDate(icalDate: string): Date | null { + const match = icalDate.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/); + if (!match) { + return null; + } + + const year = Number(match[1]); + const month = Number(match[2]) - 1; + const day = Number(match[3]); + const hour = match[4] ? Number(match[4]) : 0; + const minute = match[5] ? Number(match[5]) : 0; + const second = match[6] ? Number(match[6]) : 0; + return new Date(Date.UTC(year, month, day, hour, minute, second)); +} diff --git a/src/index.ts b/src/index.ts index 2f54cce..eb5a515 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,24 @@ import { Tool, } from "@modelcontextprotocol/sdk/types.js"; import axios, { AxiosInstance } from "axios"; -import { parseISO, format } from "date-fns"; +import { format } from "date-fns"; +import { + CalendarInfo, + buildCalendarDiscoveryPropfindBody, + buildCalendarEventsReportBody, + buildTasksReportBody, + dedupeEvents, + formatICalDate, + formatICalDateTimeUtc, + getCalDAVXmlHeaders, + getEventSortTimestamp, + parseCalendarsFromPROPFIND, + parseEventsFromCalDAV, + parseISODateOnlyLocalMidnight, + parseTasksFromCalDAV, + resolveCalendarSelectors, + stripEventInternalFields, +} from "./caldav.js"; interface NextcloudConfig { url: string; @@ -20,9 +37,11 @@ class NextcloudMCPServer { private server: Server; private axiosInstance: AxiosInstance; private config: NextcloudConfig; + private debugEnabled: boolean; constructor(config: NextcloudConfig) { this.config = config; + this.debugEnabled = process.env.DEBUG_NEXTCLOUD_MCP === "1"; this.server = new Server( { name: "nextcloud-mcp-server", @@ -85,6 +104,8 @@ class NextcloudMCPServer { return await this.updateTask(args as any); case "get_calendar_events": return await this.getCalendarEvents(args as any); + case "list_calendars": + return await this.listCalendars(); case "create_calendar_event": return await this.createCalendarEvent(args as any); case "get_notes": @@ -207,6 +228,23 @@ class NextcloudMCPServer { 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", @@ -215,6 +253,15 @@ class NextcloudMCPServer { }, }, }, + { + name: "list_calendars", + description: + "List available Nextcloud CalDAV calendars with href and supported components", + inputSchema: { + type: "object", + properties: {}, + }, + }, { name: "create_calendar_event", description: "Create a new calendar event in Nextcloud", @@ -229,20 +276,57 @@ class NextcloudMCPServer { 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)", + 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)", + 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", "startDateTime", "endDateTime"], + required: ["summary"], }, }, // Notes tools @@ -329,30 +413,16 @@ class NextcloudMCPServer { // CalDAV REPORT request to get tasks const caldavPath = `/remote.php/dav/calendars/${this.config.username}/tasks/`; - const requestBody = ` - - - - - - - - - - -`; + const requestBody = buildTasksReportBody(); const response = await this.axiosInstance.request({ method: "REPORT", url: caldavPath, data: requestBody, - headers: { - "Content-Type": "application/xml", - Depth: "1", - }, + headers: getCalDAVXmlHeaders("1"), }); - const tasks = this.parseTasksFromCalDAV(response.data, status, limit); + const tasks = parseTasksFromCalDAV(response.data, status, limit); return { content: [ @@ -367,72 +437,6 @@ class NextcloudMCPServer { } } - private parseTasksFromCalDAV( - xmlData: string, - status: string, - limit: number - ): any[] { - const tasks: any[] = []; - - // Basic XML parsing for VTODO components - const todoMatches = xmlData.matchAll( - /]*>([\s\S]*?)<\/c:calendar-data>/g - ); - - for (const match of todoMatches) { - if (tasks.length >= limit) break; - - const todoData = match[1]; - const task = this.parseVTODO(todoData); - - if (task) { - if (status === "all") { - tasks.push(task); - } else if ( - status === "completed" && - task.status === "COMPLETED" - ) { - tasks.push(task); - } else if ( - status === "open" && - task.status !== "COMPLETED" - ) { - tasks.push(task); - } - } - } - - return tasks; - } - - private parseVTODO(todoData: string): any | null { - const lines = todoData.split(/\r?\n/); - const task: any = {}; - - for (const line of lines) { - if (line.startsWith("UID:")) { - task.uid = line.substring(4).trim(); - } else if (line.startsWith("SUMMARY:")) { - task.summary = line.substring(8).trim(); - } else if (line.startsWith("STATUS:")) { - task.status = line.substring(7).trim(); - } else if (line.startsWith("PERCENT-COMPLETE:")) { - task.percentComplete = parseInt(line.substring(17).trim()); - } else if (line.startsWith("DUE")) { - const dueMatch = line.match(/DUE[^:]*:(\d{8}T?\d{6}Z?)/); - if (dueMatch) { - task.due = this.parseICalDate(dueMatch[1]); - } - } else if (line.startsWith("PRIORITY:")) { - task.priority = parseInt(line.substring(9).trim()); - } else if (line.startsWith("DESCRIPTION:")) { - task.description = line.substring(12).trim(); - } - } - - return task.uid ? task : null; - } - private async createTask(args: any) { const { summary, description, due, priority } = args; const uid = this.generateUID(); @@ -444,13 +448,13 @@ BEGIN:VTODO UID:${uid} SUMMARY:${summary} STATUS:NEEDS-ACTION -CREATED:${this.formatICalDateTime(new Date())}`; +CREATED:${formatICalDateTimeUtc(new Date())}`; if (description) { vtodo += `\nDESCRIPTION:${description}`; } if (due) { - vtodo += `\nDUE:${this.formatICalDate(new Date(due))}`; + vtodo += `\nDUE:${formatICalDate(new Date(due))}`; } if (priority) { vtodo += `\nPRIORITY:${priority}`; @@ -515,7 +519,7 @@ END:VCALENDAR`; // Update LAST-MODIFIED vtodo = vtodo.replace( /LAST-MODIFIED:.*/, - `LAST-MODIFIED:${this.formatICalDateTime(new Date())}` + `LAST-MODIFIED:${formatICalDateTimeUtc(new Date())}` ); await this.axiosInstance.put(caldavPath, vtodo, { @@ -538,6 +542,18 @@ END:VCALENDAR`; } // ========== CALENDAR METHODS ========== + private async listCalendars() { + const calendars = await this.discoverCalendars(); + return { + content: [ + { + type: "text", + text: JSON.stringify(calendars, null, 2), + }, + ], + }; + } + private async getCalendarEvents(args: any) { const startDate = args.startDate || format(new Date(), "yyyy-MM-dd"); const endDate = @@ -547,38 +563,107 @@ END:VCALENDAR`; "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 caldavPath = `/remote.php/dav/calendars/${this.config.username}/personal/`; + const calendars = await this.discoverCalendars(); + const veventCalendars = calendars.filter((calendar) => + calendar.components.includes("VEVENT") + ); - const requestBody = ` - - - - - - - - - - - - -`; + if (veventCalendars.length === 0) { + return { + content: [ + { + type: "text", + text: JSON.stringify([], null, 2), + }, + ], + }; + } - const response = await this.axiosInstance.request({ - method: "REPORT", - url: caldavPath, - data: requestBody, - headers: { - "Content-Type": "application/xml", - Depth: "1", - }, - }); + 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 events = this.parseEventsFromCalDAV(response.data, limit); + const requestBody = buildCalendarEventsReportBody(startLocal, endLocal); + + const mergedEvents: any[] = []; + const requestErrors: string[] = []; + + for (const calendar of targetCalendars) { + try { + this.debugLog(`Querying calendar href: ${calendar.href}`); + this.debugLog(`REPORT body for ${calendar.href}:\n${requestBody}`); + + const response = await this.axiosInstance.request({ + method: "REPORT", + url: calendar.href, + data: requestBody, + headers: getCalDAVXmlHeaders("1"), + }); + + const responseBody = String(response.data ?? ""); + this.debugLog( + `REPORT response for ${calendar.href}: status=${response.status} body-preview=${responseBody.slice( + 0, + 500 + )}` + ); + + const events = parseEventsFromCalDAV( + responseBody, + calendar.href, + (message) => this.debugLog(message) + ).map((event) => ({ + ...event, + calendarName: calendar.name, + calendarHref: calendar.href, + })); + mergedEvents.push(...events); + } catch (error: any) { + requestErrors.push(`${calendar.href}: ${error.message}`); + this.debugLog(`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: [ @@ -593,76 +678,123 @@ END:VCALENDAR`; } } - private parseEventsFromCalDAV(xmlData: string, limit: number): any[] { - const events: any[] = []; - - const eventMatches = xmlData.matchAll( - /]*>([\s\S]*?)<\/c:calendar-data>/g - ); - - for (const match of eventMatches) { - if (events.length >= limit) break; - - const eventData = match[1]; - const event = this.parseVEVENT(eventData); - - if (event) { - events.push(event); - } - } - - return events; - } - - private parseVEVENT(eventData: string): any | null { - const lines = eventData.split(/\r?\n/); - const event: any = {}; - - for (const line of lines) { - if (line.startsWith("UID:")) { - event.uid = line.substring(4).trim(); - } else if (line.startsWith("SUMMARY:")) { - event.summary = line.substring(8).trim(); - } else if (line.startsWith("DESCRIPTION:")) { - event.description = line.substring(12).trim(); - } else if (line.startsWith("LOCATION:")) { - event.location = line.substring(9).trim(); - } else if (line.startsWith("DTSTART")) { - const startMatch = line.match(/DTSTART[^:]*:(\d{8}T?\d{6}Z?)/); - if (startMatch) { - event.start = this.parseICalDate(startMatch[1]); - } - } else if (line.startsWith("DTEND")) { - const endMatch = line.match(/DTEND[^:]*:(\d{8}T?\d{6}Z?)/); - if (endMatch) { - event.end = this.parseICalDate(endMatch[1]); - } - } - } - - return event.uid ? event : null; - } - private async createCalendarEvent(args: any) { - const { summary, description, startDateTime, endDateTime, location } = args; + const { + summary, + description, + location, + allDay, + startDateTime, + endDateTime, + startDate, + endDate, + reminderMinutesBefore, + reminderDateTime, + reminderDescription, + } = args; const uid = this.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 args.reminderAction === "string" && args.reminderAction.trim() + ? args.reminderAction.trim().toUpperCase() + : "DISPLAY"; + const alarmDescription = this.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:${summary} -DTSTART:${this.formatICalDateTime(new Date(startDateTime))} -DTEND:${this.formatICalDateTime(new Date(endDateTime))} -CREATED:${this.formatICalDateTime(new Date())}`; +SUMMARY:${this.escapeICalText(summary)} +${dtStartLine} +${dtEndLine} +CREATED:${formatICalDateTimeUtc(new Date())}`; if (description) { - vevent += `\nDESCRIPTION:${description}`; + vevent += `\nDESCRIPTION:${this.escapeICalText(description)}`; } if (location) { - vevent += `\nLOCATION:${location}`; + vevent += `\nLOCATION:${this.escapeICalText(location)}`; } + vevent += alarmBlock; vevent += `\nEND:VEVENT END:VCALENDAR`; @@ -842,31 +974,46 @@ END:VCALENDAR`; return `${Date.now()}-${Math.random().toString(36).substring(7)}`; } - private formatICalDate(date: Date): string { - return format(date, "yyyyMMdd"); + private escapeICalText(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/\n/g, "\\n") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,"); } - private formatICalDateTime(date: Date): string { - return format(date, "yyyyMMdd'T'HHmmss'Z'"); - } - - private parseICalDate(icalDate: string): string { - // Parse iCal date format (e.g., 20240101 or 20240101T120000Z) - if (icalDate.includes("T")) { - const year = icalDate.substring(0, 4); - const month = icalDate.substring(4, 6); - const day = icalDate.substring(6, 8); - const hour = icalDate.substring(9, 11); - const minute = icalDate.substring(11, 13); - return `${year}-${month}-${day} ${hour}:${minute}`; - } else { - const year = icalDate.substring(0, 4); - const month = icalDate.substring(4, 6); - const day = icalDate.substring(6, 8); - return `${year}-${month}-${day}`; + private debugLog(message: string): void { + if (this.debugEnabled) { + console.error(`[DEBUG_NEXTCLOUD_MCP] ${message}`); } } + private async discoverCalendars(): Promise { + const calendarsRoot = `/remote.php/dav/calendars/${this.config.username}/`; + const requestBody = buildCalendarDiscoveryPropfindBody(); + + this.debugLog(`PROPFIND calendars root: ${calendarsRoot}`); + + const response = await this.axiosInstance.request({ + method: "PROPFIND", + url: calendarsRoot, + data: requestBody, + headers: getCalDAVXmlHeaders("1"), + }); + + const responseBody = String(response.data ?? ""); + this.debugLog( + `PROPFIND response status=${response.status} body-preview=${responseBody.slice( + 0, + 500 + )}` + ); + + return parseCalendarsFromPROPFIND(responseBody, calendarsRoot); + } + async run(): Promise { const transport = new StdioServerTransport(); await this.server.connect(transport);