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

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,
} 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 = `<?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>`;
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(
/<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) {
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 = `<?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="${this.formatICalDate(
new Date(startDate)
)}" end="${this.formatICalDate(new Date(endDate))}"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>`;
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(
/<c:calendar-data[^>]*>([\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<CalendarInfo[]> {
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<void> {
const transport = new StdioServerTransport();
await this.server.connect(transport);