8461970523
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
399 lines
13 KiB
TypeScript
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);
|
|
}
|