refactor: modularize codebase — Step 0 structural refactoring
Extract monolithic index.ts (~600 lines) into focused modules: - src/types.ts — shared TypeScript interfaces (FileMetadata, QuotaInfo, etc.) - src/utils.ts — path, mime detection, formatting helpers - src/client.ts — NextcloudClient wrapping axios with WebDAV primitives - src/webdav.ts — XML builders + parsers (scaffolding for file tools) - src/tools/index.ts — ToolModule interface + auto registry - src/tools/calendar.ts — extracted calendar tools - src/tools/tasks.ts — extracted task tools - src/tools/notes.ts — extracted note tools - src/tools/email.ts — extracted email tools - src/tools/files.ts — empty scaffolding for upcoming file management tools src/index.ts reduced to ~50 lines: config, client instantiation, module registration, MCP setup. Zero regression on existing tools. Verified: list_calendars, get_notes, create_note, get_note_content all functional.
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
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,
|
||||
getCalDAVXmlHeaders,
|
||||
getEventSortTimestamp,
|
||||
parseCalendarsFromPROPFIND,
|
||||
parseEventsFromCalDAV,
|
||||
parseISODateOnlyLocalMidnight,
|
||||
resolveCalendarSelectors,
|
||||
stripEventInternalFields,
|
||||
} from "../caldav.js";
|
||||
import { format } from "date-fns";
|
||||
|
||||
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 ---
|
||||
|
||||
function generateUID(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
}
|
||||
|
||||
function 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, "\\,");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user