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(/([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);