calendar-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,249 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { getCalendarPath, ensureCalendarDir, pathExists } from './paths.js';
4
+ import { formatICalDateTime, formatICalDate, parseICalDate, toISO } from './dates.js';
5
+ const PRODID = '-//Calendar MCP//EN';
6
+ const VERSION = '2.0';
7
+ const CALNAME = 'My Calendar';
8
+ const TIMEZONE = 'UTC';
9
+ const UID_DOMAIN = 'calendar.local';
10
+ /**
11
+ * Generate a unique event ID
12
+ */
13
+ export function generateUID() {
14
+ return `${uuidv4()}@${UID_DOMAIN}`;
15
+ }
16
+ /**
17
+ * Extract the ID part from a UID (without domain)
18
+ */
19
+ export function extractId(uid) {
20
+ const atIndex = uid.indexOf('@');
21
+ return atIndex > 0 ? uid.slice(0, atIndex) : uid;
22
+ }
23
+ /**
24
+ * Create a full UID from an ID
25
+ */
26
+ export function createUID(id) {
27
+ if (id.includes('@')) {
28
+ return id;
29
+ }
30
+ return `${id}@${UID_DOMAIN}`;
31
+ }
32
+ /**
33
+ * Escape special characters for iCal text fields
34
+ */
35
+ export function escapeICalText(text) {
36
+ return text
37
+ .replace(/\\/g, '\\\\')
38
+ .replace(/;/g, '\\;')
39
+ .replace(/,/g, '\\,')
40
+ .replace(/\n/g, '\\n');
41
+ }
42
+ /**
43
+ * Unescape special characters from iCal text fields
44
+ */
45
+ export function unescapeICalText(text) {
46
+ return text
47
+ .replace(/\\n/g, '\n')
48
+ .replace(/\\,/g, ',')
49
+ .replace(/\\;/g, ';')
50
+ .replace(/\\\\/g, '\\');
51
+ }
52
+ /**
53
+ * Fold long lines according to iCal spec (max 75 chars per line)
54
+ */
55
+ export function foldLine(line) {
56
+ if (line.length <= 75) {
57
+ return line;
58
+ }
59
+ const result = [];
60
+ let remaining = line;
61
+ // First line can be 75 chars
62
+ result.push(remaining.slice(0, 75));
63
+ remaining = remaining.slice(75);
64
+ // Continuation lines start with space and can be 74 chars of content
65
+ while (remaining.length > 0) {
66
+ result.push(' ' + remaining.slice(0, 74));
67
+ remaining = remaining.slice(74);
68
+ }
69
+ return result.join('\r\n');
70
+ }
71
+ /**
72
+ * Unfold lines that were folded according to iCal spec
73
+ */
74
+ export function unfoldLines(content) {
75
+ // Replace CRLF + space/tab with nothing (unfold)
76
+ return content.replace(/\r?\n[ \t]/g, '');
77
+ }
78
+ /**
79
+ * Parse iCalendar content to events
80
+ */
81
+ export function parseICalendar(content) {
82
+ const unfolded = unfoldLines(content);
83
+ const lines = unfolded.split(/\r?\n/);
84
+ const events = [];
85
+ let currentEvent = null;
86
+ for (const line of lines) {
87
+ const trimmed = line.trim();
88
+ if (!trimmed)
89
+ continue;
90
+ if (trimmed === 'BEGIN:VEVENT') {
91
+ currentEvent = { allDay: false };
92
+ continue;
93
+ }
94
+ if (trimmed === 'END:VEVENT') {
95
+ if (currentEvent && currentEvent.uid && currentEvent.dtstart && currentEvent.summary) {
96
+ events.push(icalEventToCalendarEvent(currentEvent));
97
+ }
98
+ currentEvent = null;
99
+ continue;
100
+ }
101
+ if (!currentEvent)
102
+ continue;
103
+ // Parse property
104
+ const colonIndex = trimmed.indexOf(':');
105
+ if (colonIndex === -1)
106
+ continue;
107
+ const propertyPart = trimmed.slice(0, colonIndex);
108
+ const valuePart = trimmed.slice(colonIndex + 1);
109
+ // Handle properties with parameters (e.g., DTSTART;VALUE=DATE:20250125)
110
+ const semiIndex = propertyPart.indexOf(';');
111
+ const propertyName = semiIndex > 0 ? propertyPart.slice(0, semiIndex) : propertyPart;
112
+ const params = semiIndex > 0 ? propertyPart.slice(semiIndex + 1) : '';
113
+ switch (propertyName.toUpperCase()) {
114
+ case 'UID':
115
+ currentEvent.uid = valuePart;
116
+ break;
117
+ case 'SUMMARY':
118
+ currentEvent.summary = unescapeICalText(valuePart);
119
+ break;
120
+ case 'DESCRIPTION':
121
+ currentEvent.description = unescapeICalText(valuePart);
122
+ break;
123
+ case 'LOCATION':
124
+ currentEvent.location = unescapeICalText(valuePart);
125
+ break;
126
+ case 'DTSTART': {
127
+ const isAllDay = params.toUpperCase().includes('VALUE=DATE');
128
+ const parsed = parseICalDate(valuePart);
129
+ currentEvent.dtstart = parsed.date;
130
+ currentEvent.allDay = isAllDay || parsed.allDay;
131
+ break;
132
+ }
133
+ case 'DTEND': {
134
+ const parsed = parseICalDate(valuePart);
135
+ currentEvent.dtend = parsed.date;
136
+ break;
137
+ }
138
+ case 'DTSTAMP': {
139
+ const parsed = parseICalDate(valuePart);
140
+ currentEvent.dtstamp = parsed.date;
141
+ break;
142
+ }
143
+ case 'CREATED': {
144
+ const parsed = parseICalDate(valuePart);
145
+ currentEvent.created = parsed.date;
146
+ break;
147
+ }
148
+ case 'LAST-MODIFIED': {
149
+ const parsed = parseICalDate(valuePart);
150
+ currentEvent.lastModified = parsed.date;
151
+ break;
152
+ }
153
+ case 'STATUS':
154
+ currentEvent.status = valuePart.toUpperCase();
155
+ break;
156
+ }
157
+ }
158
+ return events;
159
+ }
160
+ /**
161
+ * Convert internal ICalEvent to CalendarEvent format
162
+ */
163
+ function icalEventToCalendarEvent(event) {
164
+ return {
165
+ id: extractId(event.uid),
166
+ title: event.summary,
167
+ start: toISO(event.dtstart),
168
+ end: event.dtend ? toISO(event.dtend) : undefined,
169
+ location: event.location,
170
+ description: event.description,
171
+ allDay: event.allDay,
172
+ status: event.status ?? 'CONFIRMED',
173
+ created: event.created ? toISO(event.created) : undefined,
174
+ lastModified: event.lastModified ? toISO(event.lastModified) : undefined,
175
+ };
176
+ }
177
+ /**
178
+ * Generate iCalendar content from events
179
+ */
180
+ export function generateICalendar(events) {
181
+ const lines = [
182
+ 'BEGIN:VCALENDAR',
183
+ `VERSION:${VERSION}`,
184
+ `PRODID:${PRODID}`,
185
+ 'CALSCALE:GREGORIAN',
186
+ 'METHOD:PUBLISH',
187
+ `X-WR-CALNAME:${CALNAME}`,
188
+ `X-WR-TIMEZONE:${TIMEZONE}`,
189
+ ];
190
+ for (const event of events) {
191
+ lines.push('BEGIN:VEVENT');
192
+ lines.push(`UID:${createUID(event.id)}`);
193
+ lines.push(`DTSTAMP:${formatICalDateTime(new Date())}`);
194
+ const startDate = new Date(event.start);
195
+ if (event.allDay) {
196
+ lines.push(`DTSTART;VALUE=DATE:${formatICalDate(startDate)}`);
197
+ if (event.end) {
198
+ lines.push(`DTEND;VALUE=DATE:${formatICalDate(new Date(event.end))}`);
199
+ }
200
+ }
201
+ else {
202
+ lines.push(`DTSTART:${formatICalDateTime(startDate)}`);
203
+ if (event.end) {
204
+ lines.push(`DTEND:${formatICalDateTime(new Date(event.end))}`);
205
+ }
206
+ }
207
+ lines.push(`SUMMARY:${escapeICalText(event.title)}`);
208
+ if (event.description) {
209
+ lines.push(foldLine(`DESCRIPTION:${escapeICalText(event.description)}`));
210
+ }
211
+ if (event.location) {
212
+ lines.push(foldLine(`LOCATION:${escapeICalText(event.location)}`));
213
+ }
214
+ lines.push(`STATUS:${event.status}`);
215
+ if (event.created) {
216
+ lines.push(`CREATED:${formatICalDateTime(new Date(event.created))}`);
217
+ }
218
+ if (event.lastModified) {
219
+ lines.push(`LAST-MODIFIED:${formatICalDateTime(new Date(event.lastModified))}`);
220
+ }
221
+ lines.push('END:VEVENT');
222
+ }
223
+ lines.push('END:VCALENDAR');
224
+ // iCal uses CRLF line endings
225
+ return lines.join('\r\n') + '\r\n';
226
+ }
227
+ /**
228
+ * Read the calendar file and return parsed events
229
+ * Returns empty array if file doesn't exist
230
+ */
231
+ export async function readCalendarFile() {
232
+ const calendarPath = getCalendarPath();
233
+ const exists = await pathExists(calendarPath);
234
+ if (!exists) {
235
+ return [];
236
+ }
237
+ const content = await readFile(calendarPath, 'utf-8');
238
+ return parseICalendar(content);
239
+ }
240
+ /**
241
+ * Write events to the calendar file
242
+ * Creates the Calendar directory if it doesn't exist
243
+ */
244
+ export async function writeCalendarFile(events) {
245
+ await ensureCalendarDir();
246
+ const calendarPath = getCalendarPath();
247
+ const content = generateICalendar(events);
248
+ await writeFile(calendarPath, content, 'utf-8');
249
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Get the vault path from environment variable
3
+ * @throws Error if VAULT_PATH is not set
4
+ */
5
+ export declare function getVaultPath(): string;
6
+ /**
7
+ * Get the Calendar directory path within the vault
8
+ */
9
+ export declare function getCalendarDir(): string;
10
+ /**
11
+ * Get the full path to the events.ics file
12
+ */
13
+ export declare function getCalendarPath(): string;
14
+ /**
15
+ * Check if a path exists
16
+ */
17
+ export declare function pathExists(path: string): Promise<boolean>;
18
+ /**
19
+ * Ensure the Calendar directory exists, creating it if necessary
20
+ */
21
+ export declare function ensureCalendarDir(): Promise<void>;
@@ -0,0 +1,50 @@
1
+ import { join } from 'path';
2
+ import { mkdir, access } from 'fs/promises';
3
+ import { constants } from 'fs';
4
+ const CALENDAR_FOLDER = 'Calendar';
5
+ const CALENDAR_FILE = 'events.ics';
6
+ /**
7
+ * Get the vault path from environment variable
8
+ * @throws Error if VAULT_PATH is not set
9
+ */
10
+ export function getVaultPath() {
11
+ const vaultPath = process.env.VAULT_PATH;
12
+ if (!vaultPath) {
13
+ throw new Error('VAULT_PATH environment variable is not set');
14
+ }
15
+ return vaultPath;
16
+ }
17
+ /**
18
+ * Get the Calendar directory path within the vault
19
+ */
20
+ export function getCalendarDir() {
21
+ return join(getVaultPath(), CALENDAR_FOLDER);
22
+ }
23
+ /**
24
+ * Get the full path to the events.ics file
25
+ */
26
+ export function getCalendarPath() {
27
+ return join(getCalendarDir(), CALENDAR_FILE);
28
+ }
29
+ /**
30
+ * Check if a path exists
31
+ */
32
+ export async function pathExists(path) {
33
+ try {
34
+ await access(path, constants.F_OK);
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /**
42
+ * Ensure the Calendar directory exists, creating it if necessary
43
+ */
44
+ export async function ensureCalendarDir() {
45
+ const calendarDir = getCalendarDir();
46
+ const exists = await pathExists(calendarDir);
47
+ if (!exists) {
48
+ await mkdir(calendarDir, { recursive: true });
49
+ }
50
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "calendar-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for calendar management via Claude Code - uses iCalendar format",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Masbuc53/PersonalCalendar.git",
10
+ "directory": "calendar-mcp"
11
+ },
12
+ "homepage": "https://github.com/Masbuc53/PersonalCalendar/tree/main/calendar-mcp#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/Masbuc53/PersonalCalendar/issues"
15
+ },
16
+ "bin": {
17
+ "calendar-mcp": "dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "start": "node dist/index.js",
26
+ "dev": "tsc --watch",
27
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
28
+ "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "keywords": [
32
+ "mcp",
33
+ "model-context-protocol",
34
+ "claude",
35
+ "claude-code",
36
+ "calendar",
37
+ "icalendar",
38
+ "obsidian"
39
+ ],
40
+ "author": "Mason Buchanan",
41
+ "license": "MIT",
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.0.0",
44
+ "chrono-node": "^2.7.0",
45
+ "ical.js": "^2.0.0",
46
+ "simple-git": "^3.22.0",
47
+ "uuid": "^9.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/jest": "^29.5.0",
51
+ "@types/node": "^20.10.0",
52
+ "@types/uuid": "^9.0.0",
53
+ "jest": "^29.7.0",
54
+ "ts-jest": "^29.1.0",
55
+ "typescript": "^5.3.0"
56
+ },
57
+ "engines": {
58
+ "node": ">=18.0.0"
59
+ }
60
+ }