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:
2026-05-11 13:43:36 +02:00
parent d308d8375e
commit 84c5bdd90e
12 changed files with 2746 additions and 1020 deletions
+412
View File
@@ -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);
}