update caldav

This commit is contained in:
2026-03-09 16:57:33 +01:00
parent 7da30ae044
commit f114308447
6 changed files with 988 additions and 210 deletions

View File

@@ -109,7 +109,7 @@ Before asking Claude to use Nextcloud:
### "Calendar not found" or "Tasks not found" ### "Calendar not found" or "Tasks not found"
- Check that you have at least one calendar created - Check that you have at least one calendar created
- Verify the Tasks app is installed and has a task list - 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 ### Claude doesn't show Nextcloud tools
- Verify Claude Desktop config file syntax (use a JSON validator) - Verify Claude Desktop config file syntax (use a JSON validator)
@@ -120,10 +120,54 @@ Before asking Claude to use Nextcloud:
## 🎯 Next Steps ## 🎯 Next Steps
Once working: 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 2. Add more tools as needed for your workflow
3. Check out the full README.md for advanced features 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 '<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:displayname />
<d:resourcetype />
<c:supported-calendar-component-set />
</d:prop>
</d:propfind>' \
"$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 '<?xml version="1.0" encoding="UTF-8"?>
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
<d:prop>
<d:getetag />
<c:calendar-data />
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="20260201T000000Z" end="20260301T000000Z"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>' \
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/personal/"
```
## 📚 Example Use Cases ## 📚 Example Use Cases
**Task Management**: **Task Management**:

View File

@@ -15,6 +15,8 @@ A Model Context Protocol (MCP) server that integrates with Nextcloud to provide
### Calendar ### Calendar
- ✅ Get calendar events with date range filtering - ✅ 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 - ✅ Create new calendar events with details and location
### Notes ### Notes
@@ -125,9 +127,17 @@ Once connected, Claude can use these tools:
- `update_task` - Update existing task (mark complete, change details) - `update_task` - Update existing task (mark complete, change details)
### Calendar ### 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 - `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 ### Notes
- `get_notes` - List all notes - `get_notes` - List all notes
- `create_note` - Create new note with markdown - `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 - Check that required apps (Tasks, Calendar, Notes) are installed
### CalDAV Issues ### CalDAV Issues
- Verify calendar/task list names match your Nextcloud setup - Use `list_calendars` to discover calendar names/hrefs from your server
- Default calendar name is "personal" - adjust in code if needed - Set `DEBUG_NEXTCLOUD_MCP=1` to log CalDAV requests and parsing details
- Default task list name is "tasks" - adjust in code if needed - 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 '<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:displayname />
<d:resourcetype />
<c:supported-calendar-component-set />
</d:prop>
</d:propfind>' \
"$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 '<?xml version="1.0" encoding="UTF-8"?>
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
<d:prop>
<d:getetag />
<c:calendar-data />
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="20260201T000000Z" end="20260301T000000Z"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>' \
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/personal/"
```
### Email Issues ### Email Issues
- Ensure Nextcloud Mail app is installed and configured - Ensure Nextcloud Mail app is installed and configured
@@ -187,15 +240,8 @@ Check the MCP server logs in Claude Desktop:
## Customization ## Customization
### Changing Calendar Names ### Calendar Selection
Edit `src/index.ts` and update the calendar paths: Use `list_calendars` and pass `calendar` / `calendars` to `get_calendar_events` to target specific calendars by name or href.
```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/`;
```
### Changing Task List Names ### Changing Task List Names
```typescript ```typescript

9
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@modelcontextprotocol/sdk": "^1.20.2", "@modelcontextprotocol/sdk": "^1.20.2",
"axios": "^1.12.2", "axios": "^1.12.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"ical.js": "^2.2.1",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"bin": { "bin": {
@@ -882,6 +883,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
@@ -1188,6 +1190,12 @@
"node": ">= 0.8" "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": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "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", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -13,13 +13,21 @@
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"test-connection": "node test-connection.js" "test-connection": "node test-connection.js"
}, },
"keywords": ["mcp", "nextcloud", "calendar", "tasks", "notes", "email"], "keywords": [
"mcp",
"nextcloud",
"calendar",
"tasks",
"notes",
"email"
],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.20.2", "@modelcontextprotocol/sdk": "^1.20.2",
"axios": "^1.12.2", "axios": "^1.12.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"ical.js": "^2.2.1",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {

524
src/caldav.ts Normal file
View File

@@ -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<string, string> {
return {
Accept: "application/xml",
"Content-Type": "application/xml; charset=utf-8",
Depth: depth,
};
}
export function buildTasksReportBody(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
<d:prop>
<d:getetag />
<c:calendar-data />
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VTODO" />
</c:comp-filter>
</c:filter>
</c:calendar-query>`;
}
export function buildCalendarEventsReportBody(start: Date, end: Date): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
<d:prop>
<d:getetag />
<c:calendar-data>
<c:expand start="${formatICalDateTimeUtc(start)}" end="${formatICalDateTimeUtc(end)}"/>
</c:calendar-data>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="${formatICalDateTimeUtc(start)}" end="${formatICalDateTimeUtc(end)}"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>`;
}
export function buildCalendarDiscoveryPropfindBody(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:displayname />
<d:resourcetype />
<c:supported-calendar-component-set />
</d:prop>
</d:propfind>`;
}
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<string, CalendarInfo>();
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<string, CalendarInfo>();
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<string, any>();
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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&apos;/g, "'")
.replace(/&amp;/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));
}

View File

@@ -8,7 +8,24 @@ import {
Tool, Tool,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import axios, { AxiosInstance } from "axios"; 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 { interface NextcloudConfig {
url: string; url: string;
@@ -20,9 +37,11 @@ class NextcloudMCPServer {
private server: Server; private server: Server;
private axiosInstance: AxiosInstance; private axiosInstance: AxiosInstance;
private config: NextcloudConfig; private config: NextcloudConfig;
private debugEnabled: boolean;
constructor(config: NextcloudConfig) { constructor(config: NextcloudConfig) {
this.config = config; this.config = config;
this.debugEnabled = process.env.DEBUG_NEXTCLOUD_MCP === "1";
this.server = new Server( this.server = new Server(
{ {
name: "nextcloud-mcp-server", name: "nextcloud-mcp-server",
@@ -85,6 +104,8 @@ class NextcloudMCPServer {
return await this.updateTask(args as any); return await this.updateTask(args as any);
case "get_calendar_events": case "get_calendar_events":
return await this.getCalendarEvents(args as any); return await this.getCalendarEvents(args as any);
case "list_calendars":
return await this.listCalendars();
case "create_calendar_event": case "create_calendar_event":
return await this.createCalendarEvent(args as any); return await this.createCalendarEvent(args as any);
case "get_notes": case "get_notes":
@@ -207,6 +228,23 @@ class NextcloudMCPServer {
description: description:
"End date in ISO format (YYYY-MM-DD). Defaults to 30 days from start.", "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: { limit: {
type: "number", type: "number",
description: "Maximum number of events to return", 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", name: "create_calendar_event",
description: "Create a new calendar event in Nextcloud", description: "Create a new calendar event in Nextcloud",
@@ -229,20 +276,57 @@ class NextcloudMCPServer {
type: "string", type: "string",
description: "Event description (optional)", description: "Event description (optional)",
}, },
allDay: {
type: "boolean",
description:
"Create an all-day event. Use startDate/endDate when true.",
default: false,
},
startDateTime: { startDateTime: {
type: "string", 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: { endDateTime: {
type: "string", 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: { location: {
type: "string", type: "string",
description: "Event location (optional)", description: "Event location (optional)",
}, },
}, },
required: ["summary", "startDateTime", "endDateTime"], required: ["summary"],
}, },
}, },
// Notes tools // Notes tools
@@ -329,30 +413,16 @@ class NextcloudMCPServer {
// CalDAV REPORT request to get tasks // CalDAV REPORT request to get tasks
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/tasks/`; const caldavPath = `/remote.php/dav/calendars/${this.config.username}/tasks/`;
const requestBody = `<?xml version="1.0" encoding="UTF-8"?> const requestBody = buildTasksReportBody();
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
<d:prop>
<d:getetag />
<c:calendar-data />
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VTODO" />
</c:comp-filter>
</c:filter>
</c:calendar-query>`;
const response = await this.axiosInstance.request({ const response = await this.axiosInstance.request({
method: "REPORT", method: "REPORT",
url: caldavPath, url: caldavPath,
data: requestBody, data: requestBody,
headers: { headers: getCalDAVXmlHeaders("1"),
"Content-Type": "application/xml",
Depth: "1",
},
}); });
const tasks = this.parseTasksFromCalDAV(response.data, status, limit); const tasks = parseTasksFromCalDAV(response.data, status, limit);
return { return {
content: [ 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(
/<c:calendar-data[^>]*>([\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) { private async createTask(args: any) {
const { summary, description, due, priority } = args; const { summary, description, due, priority } = args;
const uid = this.generateUID(); const uid = this.generateUID();
@@ -444,13 +448,13 @@ BEGIN:VTODO
UID:${uid} UID:${uid}
SUMMARY:${summary} SUMMARY:${summary}
STATUS:NEEDS-ACTION STATUS:NEEDS-ACTION
CREATED:${this.formatICalDateTime(new Date())}`; CREATED:${formatICalDateTimeUtc(new Date())}`;
if (description) { if (description) {
vtodo += `\nDESCRIPTION:${description}`; vtodo += `\nDESCRIPTION:${description}`;
} }
if (due) { if (due) {
vtodo += `\nDUE:${this.formatICalDate(new Date(due))}`; vtodo += `\nDUE:${formatICalDate(new Date(due))}`;
} }
if (priority) { if (priority) {
vtodo += `\nPRIORITY:${priority}`; vtodo += `\nPRIORITY:${priority}`;
@@ -515,7 +519,7 @@ END:VCALENDAR`;
// Update LAST-MODIFIED // Update LAST-MODIFIED
vtodo = vtodo.replace( vtodo = vtodo.replace(
/LAST-MODIFIED:.*/, /LAST-MODIFIED:.*/,
`LAST-MODIFIED:${this.formatICalDateTime(new Date())}` `LAST-MODIFIED:${formatICalDateTimeUtc(new Date())}`
); );
await this.axiosInstance.put(caldavPath, vtodo, { await this.axiosInstance.put(caldavPath, vtodo, {
@@ -538,6 +542,18 @@ END:VCALENDAR`;
} }
// ========== CALENDAR METHODS ========== // ========== 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) { private async getCalendarEvents(args: any) {
const startDate = args.startDate || format(new Date(), "yyyy-MM-dd"); const startDate = args.startDate || format(new Date(), "yyyy-MM-dd");
const endDate = const endDate =
@@ -547,38 +563,107 @@ END:VCALENDAR`;
"yyyy-MM-dd" "yyyy-MM-dd"
); );
const limit = args.limit || 50; 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 { 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 = `<?xml version="1.0" encoding="UTF-8"?> if (veventCalendars.length === 0) {
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:"> return {
<d:prop> content: [
<d:getetag /> {
<c:calendar-data /> type: "text",
</d:prop> text: JSON.stringify([], null, 2),
<c:filter> },
<c:comp-filter name="VCALENDAR"> ],
<c:comp-filter name="VEVENT"> };
<c:time-range start="${this.formatICalDate( }
new Date(startDate)
)}" end="${this.formatICalDate(new Date(endDate))}"/> let targetCalendars: CalendarInfo[] = [];
</c:comp-filter> if (selectorList.length > 0) {
</c:comp-filter> targetCalendars = resolveCalendarSelectors(selectorList, calendars);
</c:filter> } else if (includeAllCalendars) {
</c:calendar-query>`; 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 {
this.debugLog(`Querying calendar href: ${calendar.href}`);
this.debugLog(`REPORT body for ${calendar.href}:\n${requestBody}`);
const response = await this.axiosInstance.request({ const response = await this.axiosInstance.request({
method: "REPORT", method: "REPORT",
url: caldavPath, url: calendar.href,
data: requestBody, data: requestBody,
headers: { headers: getCalDAVXmlHeaders("1"),
"Content-Type": "application/xml",
Depth: "1",
},
}); });
const events = this.parseEventsFromCalDAV(response.data, limit); 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 { return {
content: [ content: [
@@ -593,76 +678,123 @@ END:VCALENDAR`;
} }
} }
private parseEventsFromCalDAV(xmlData: string, limit: number): any[] { private async createCalendarEvent(args: any) {
const events: any[] = []; const {
summary,
description,
location,
allDay,
startDateTime,
endDateTime,
startDate,
endDate,
reminderMinutesBefore,
reminderDateTime,
reminderDescription,
} = args;
const uid = this.generateUID();
const isAllDay = allDay === true;
const eventMatches = xmlData.matchAll( let dtStartLine = "";
/<c:calendar-data[^>]*>([\s\S]*?)<\/c:calendar-data>/g 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"
); );
for (const match of eventMatches) { if (reminderMinutesBefore !== undefined) {
if (events.length >= limit) break; const minutes = Number(reminderMinutesBefore);
if (!Number.isFinite(minutes) || minutes < 0) {
const eventData = match[1]; throw new Error("reminderMinutesBefore must be a number >= 0");
const event = this.parseVEVENT(eventData); }
const roundedMinutes = Math.floor(minutes);
if (event) { alarmBlock = `\nBEGIN:VALARM
events.push(event); 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`;
} }
} }
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 uid = this.generateUID();
let vevent = `BEGIN:VCALENDAR let vevent = `BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
PRODID:-//Nextcloud MCP Server//EN PRODID:-//Nextcloud MCP Server//EN
BEGIN:VEVENT BEGIN:VEVENT
UID:${uid} UID:${uid}
SUMMARY:${summary} SUMMARY:${this.escapeICalText(summary)}
DTSTART:${this.formatICalDateTime(new Date(startDateTime))} ${dtStartLine}
DTEND:${this.formatICalDateTime(new Date(endDateTime))} ${dtEndLine}
CREATED:${this.formatICalDateTime(new Date())}`; CREATED:${formatICalDateTimeUtc(new Date())}`;
if (description) { if (description) {
vevent += `\nDESCRIPTION:${description}`; vevent += `\nDESCRIPTION:${this.escapeICalText(description)}`;
} }
if (location) { if (location) {
vevent += `\nLOCATION:${location}`; vevent += `\nLOCATION:${this.escapeICalText(location)}`;
} }
vevent += alarmBlock;
vevent += `\nEND:VEVENT vevent += `\nEND:VEVENT
END:VCALENDAR`; END:VCALENDAR`;
@@ -842,29 +974,44 @@ END:VCALENDAR`;
return `${Date.now()}-${Math.random().toString(36).substring(7)}`; return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
} }
private formatICalDate(date: Date): string { private escapeICalText(value: string): string {
return format(date, "yyyyMMdd"); 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 { private debugLog(message: string): void {
return format(date, "yyyyMMdd'T'HHmmss'Z'"); if (this.debugEnabled) {
console.error(`[DEBUG_NEXTCLOUD_MCP] ${message}`);
}
} }
private parseICalDate(icalDate: string): string { private async discoverCalendars(): Promise<CalendarInfo[]> {
// Parse iCal date format (e.g., 20240101 or 20240101T120000Z) const calendarsRoot = `/remote.php/dav/calendars/${this.config.username}/`;
if (icalDate.includes("T")) { const requestBody = buildCalendarDiscoveryPropfindBody();
const year = icalDate.substring(0, 4);
const month = icalDate.substring(4, 6); this.debugLog(`PROPFIND calendars root: ${calendarsRoot}`);
const day = icalDate.substring(6, 8);
const hour = icalDate.substring(9, 11); const response = await this.axiosInstance.request({
const minute = icalDate.substring(11, 13); method: "PROPFIND",
return `${year}-${month}-${day} ${hour}:${minute}`; url: calendarsRoot,
} else { data: requestBody,
const year = icalDate.substring(0, 4); headers: getCalDAVXmlHeaders("1"),
const month = icalDate.substring(4, 6); });
const day = icalDate.substring(6, 8);
return `${year}-${month}-${day}`; 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<void> { async run(): Promise<void> {