update caldav
This commit is contained in:
@@ -109,7 +109,7 @@ Before asking Claude to use Nextcloud:
|
||||
### "Calendar not found" or "Tasks not found"
|
||||
- Check that you have at least one calendar created
|
||||
- Verify the Tasks app is installed and has a task list
|
||||
- See customization section in README for different calendar names
|
||||
- Use `list_calendars` to discover exact calendar names/hrefs
|
||||
|
||||
### Claude doesn't show Nextcloud tools
|
||||
- Verify Claude Desktop config file syntax (use a JSON validator)
|
||||
@@ -120,10 +120,54 @@ Before asking Claude to use Nextcloud:
|
||||
## 🎯 Next Steps
|
||||
|
||||
Once working:
|
||||
1. Customize calendar/task list names in `src/index.ts` if needed
|
||||
1. Use `list_calendars`, then call `get_calendar_events` with `calendar` or `calendars` when you want specific calendars
|
||||
2. Add more tools as needed for your workflow
|
||||
3. Check out the full README.md for advanced features
|
||||
|
||||
## 🔍 CalDAV Quick Debug (curl)
|
||||
|
||||
List calendars:
|
||||
```bash
|
||||
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
||||
-X PROPFIND \
|
||||
-H "Depth: 1" \
|
||||
-H "Accept: application/xml" \
|
||||
-H "Content-Type: application/xml; charset=utf-8" \
|
||||
--data '<?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>' \
|
||||
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/"
|
||||
```
|
||||
|
||||
Query events:
|
||||
```bash
|
||||
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
||||
-X REPORT \
|
||||
-H "Depth: 1" \
|
||||
-H "Accept: application/xml" \
|
||||
-H "Content-Type: application/xml; charset=utf-8" \
|
||||
--data '<?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="20260201T000000Z" end="20260301T000000Z"/>
|
||||
</c:comp-filter>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>' \
|
||||
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/personal/"
|
||||
```
|
||||
|
||||
## 📚 Example Use Cases
|
||||
|
||||
**Task Management**:
|
||||
|
||||
72
README.md
72
README.md
@@ -15,6 +15,8 @@ A Model Context Protocol (MCP) server that integrates with Nextcloud to provide
|
||||
|
||||
### Calendar
|
||||
- ✅ Get calendar events with date range filtering
|
||||
- ✅ Query one, many, or all VEVENT calendars automatically
|
||||
- ✅ Discover available calendars and supported components
|
||||
- ✅ Create new calendar events with details and location
|
||||
|
||||
### Notes
|
||||
@@ -125,9 +127,17 @@ Once connected, Claude can use these tools:
|
||||
- `update_task` - Update existing task (mark complete, change details)
|
||||
|
||||
### Calendar
|
||||
- `get_calendar_events` - Get events in date range
|
||||
- `list_calendars` - List available calendars (`name`, `href`, `components`)
|
||||
- `get_calendar_events` - Get events in date range across selected calendars
|
||||
- `create_calendar_event` - Create new event with details
|
||||
|
||||
`get_calendar_events` supports:
|
||||
- `startDate` / `endDate` (YYYY-MM-DD)
|
||||
- `calendar` (single calendar name or href)
|
||||
- `calendars` (array of names/hrefs)
|
||||
- `includeAllCalendars` (default `true`, queries all VEVENT calendars when no selectors are provided)
|
||||
- `limit`
|
||||
|
||||
### Notes
|
||||
- `get_notes` - List all notes
|
||||
- `create_note` - Create new note with markdown
|
||||
@@ -158,9 +168,52 @@ Once the MCP server is connected, you can ask Claude:
|
||||
- Check that required apps (Tasks, Calendar, Notes) are installed
|
||||
|
||||
### CalDAV Issues
|
||||
- Verify calendar/task list names match your Nextcloud setup
|
||||
- Default calendar name is "personal" - adjust in code if needed
|
||||
- Default task list name is "tasks" - adjust in code if needed
|
||||
- Use `list_calendars` to discover calendar names/hrefs from your server
|
||||
- Set `DEBUG_NEXTCLOUD_MCP=1` to log CalDAV requests and parsing details
|
||||
- Default task list name is still `tasks` for task operations
|
||||
|
||||
### Debugging with curl
|
||||
List calendars with PROPFIND:
|
||||
```bash
|
||||
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
||||
-X PROPFIND \
|
||||
-H "Depth: 1" \
|
||||
-H "Accept: application/xml" \
|
||||
-H "Content-Type: application/xml; charset=utf-8" \
|
||||
--data '<?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>' \
|
||||
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/"
|
||||
```
|
||||
|
||||
Query events with REPORT:
|
||||
```bash
|
||||
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
||||
-X REPORT \
|
||||
-H "Depth: 1" \
|
||||
-H "Accept: application/xml" \
|
||||
-H "Content-Type: application/xml; charset=utf-8" \
|
||||
--data '<?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="20260201T000000Z" end="20260301T000000Z"/>
|
||||
</c:comp-filter>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>' \
|
||||
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/personal/"
|
||||
```
|
||||
|
||||
### Email Issues
|
||||
- Ensure Nextcloud Mail app is installed and configured
|
||||
@@ -187,15 +240,8 @@ Check the MCP server logs in Claude Desktop:
|
||||
|
||||
## Customization
|
||||
|
||||
### Changing Calendar Names
|
||||
Edit `src/index.ts` and update the calendar paths:
|
||||
```typescript
|
||||
// Default personal calendar
|
||||
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/personal/`;
|
||||
|
||||
// For a different calendar, change "personal" to your calendar name
|
||||
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/work/`;
|
||||
```
|
||||
### Calendar Selection
|
||||
Use `list_calendars` and pass `calendar` / `calendars` to `get_calendar_events` to target specific calendars by name or href.
|
||||
|
||||
### Changing Task List Names
|
||||
```typescript
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@modelcontextprotocol/sdk": "^1.20.2",
|
||||
"axios": "^1.12.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"ical.js": "^2.2.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"bin": {
|
||||
@@ -882,6 +883,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
@@ -1188,6 +1190,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ical.js": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz",
|
||||
"integrity": "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -1775,6 +1783,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -13,13 +13,21 @@
|
||||
"dev": "tsx src/index.ts",
|
||||
"test-connection": "node test-connection.js"
|
||||
},
|
||||
"keywords": ["mcp", "nextcloud", "calendar", "tasks", "notes", "email"],
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"nextcloud",
|
||||
"calendar",
|
||||
"tasks",
|
||||
"notes",
|
||||
"email"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.20.2",
|
||||
"axios": "^1.12.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"ical.js": "^2.2.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
524
src/caldav.ts
Normal file
524
src/caldav.ts
Normal 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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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));
|
||||
}
|
||||
535
src/index.ts
535
src/index.ts
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user