Files
nextcloud-mcp/src/tools/calendar.ts
T
bea 8461970523 Step 10: Code review, testing & documentation
Code Review (REVIEW.md):
- Fixed parseSearchResponse skipping first result (critical bug)
- Fixed trashbin/versions/chunked paths missing /remote.php/dav prefix
- Fixed trash_restore to use original file location instead of /restore endpoint
- Fixed createTask/updateTask missing iCal text escaping
- Added 409 handling for createFolder (parent missing)
- Extracted duplicate decodeXmlText to utils.ts
- Extracted duplicate generateUID to utils.ts (shared with calendar/tasks)
- Removed 5 dead code functions (parseVEVENT, extractVEventBlocks, unfoldICalLines, getCalDAVXmlHeaders, local decodeXmlText)
- Cleaned unused imports across all tool files

Testing (RESULTS.md):
- 35 tests passed, 1 skipped (trash_empty), 1 server limitation (bulk_upload)
- Tested all 21+ file tools, edge cases (spaces, unicode, overwrite, empty folders)
- Verified chunked upload end-to-end

Documentation (README.md):
- Complete tool reference (21 file + 10 other tools)
- Quick start, CLI usage, size limits, troubleshooting
- Architecture overview
2026-05-11 18:05:37 +02:00

399 lines
13 KiB
TypeScript

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