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.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # Calendar MCP Server
2
+
3
+ MCP (Model Context Protocol) server for calendar management via Claude Code. Uses iCalendar format (RFC 5545) for data storage.
4
+
5
+ ## Features
6
+
7
+ - **6 Calendar Tools**: Create, list, update, delete, find free time, and search events
8
+ - **Natural Language Dates**: Supports inputs like "tomorrow at 2pm" or "next Tuesday"
9
+ - **iCalendar Format**: Standard `.ics` format for portability
10
+ - **Git Integration**: Automatic commits for change tracking
11
+ - **Multi-Device Support**: Configure per-device vault paths
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install
17
+ npm run build
18
+ ```
19
+
20
+ ## Configuration
21
+
22
+ Add to your Claude Code MCP settings (`~/.claude/settings.json`):
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "calendar": {
28
+ "command": "node",
29
+ "args": ["/path/to/calendar-mcp/dist/index.js"],
30
+ "env": {
31
+ "VAULT_PATH": "/path/to/your/obsidian-vault"
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ The server creates and manages a `Calendar/events.ics` file within your vault.
39
+
40
+ ## Tools
41
+
42
+ ### calendar_create_event
43
+
44
+ Create a new calendar event.
45
+
46
+ ```
47
+ Parameters:
48
+ - title (required): Event title
49
+ - start (required): Start date/time (ISO 8601 or natural language)
50
+ - end: End date/time (defaults to start + 1 hour)
51
+ - location: Event location
52
+ - description: Event description
53
+ - allDay: Whether this is an all-day event
54
+ ```
55
+
56
+ ### calendar_list_events
57
+
58
+ List calendar events with optional filtering.
59
+
60
+ ```
61
+ Parameters:
62
+ - startDate: Filter events after this date
63
+ - endDate: Filter events before this date
64
+ - limit: Maximum events to return (default: 50)
65
+ - sortOrder: 'asc' or 'desc' (default: 'asc')
66
+ ```
67
+
68
+ ### calendar_update_event
69
+
70
+ Update an existing event.
71
+
72
+ ```
73
+ Parameters:
74
+ - id (required): Event ID to update
75
+ - title: New title
76
+ - start: New start date/time
77
+ - end: New end date/time
78
+ - location: New location
79
+ - description: New description
80
+ ```
81
+
82
+ ### calendar_delete_event
83
+
84
+ Delete an event by ID.
85
+
86
+ ```
87
+ Parameters:
88
+ - id (required): Event ID to delete
89
+ ```
90
+
91
+ ### calendar_find_free_time
92
+
93
+ Find available time slots.
94
+
95
+ ```
96
+ Parameters:
97
+ - duration (required): Duration in minutes
98
+ - startDate (required): Start of search range
99
+ - endDate (required): End of search range
100
+ - workingHoursOnly: Only 9am-5pm (default: true)
101
+ - maxResults: Maximum slots to return (default: 5)
102
+ ```
103
+
104
+ ### calendar_search_events
105
+
106
+ Search for events by text or criteria.
107
+
108
+ ```
109
+ Parameters:
110
+ - query: Search text (searches title, description, location)
111
+ - startDate: Filter after this date
112
+ - endDate: Filter before this date
113
+ - location: Filter by location
114
+ - limit: Maximum results (default: 20)
115
+ ```
116
+
117
+ ## Development
118
+
119
+ ```bash
120
+ # Install dependencies
121
+ npm install
122
+
123
+ # Build TypeScript
124
+ npm run build
125
+
126
+ # Run tests
127
+ npm test
128
+
129
+ # Run tests with coverage
130
+ npm run test:coverage
131
+
132
+ # Watch mode for development
133
+ npm run dev
134
+ ```
135
+
136
+ ## Data Format
137
+
138
+ Calendar data is stored in standard iCalendar format:
139
+
140
+ ```ics
141
+ BEGIN:VCALENDAR
142
+ VERSION:2.0
143
+ PRODID:-//Calendar MCP//EN
144
+ BEGIN:VEVENT
145
+ UID:uuid@calendar.local
146
+ DTSTAMP:20250125T120000Z
147
+ DTSTART:20250125T140000Z
148
+ DTEND:20250125T150000Z
149
+ SUMMARY:Event Title
150
+ LOCATION:Event Location
151
+ DESCRIPTION:Event Description
152
+ STATUS:CONFIRMED
153
+ END:VEVENT
154
+ END:VCALENDAR
155
+ ```
156
+
157
+ ## License
158
+
159
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
+ import { createEvent } from './tools/create-event.js';
6
+ import { listEvents } from './tools/list-events.js';
7
+ import { updateEvent } from './tools/update-event.js';
8
+ import { deleteEvent } from './tools/delete-event.js';
9
+ import { findFreeTime } from './tools/find-free.js';
10
+ import { searchEvents } from './tools/search-events.js';
11
+ const server = new Server({
12
+ name: 'calendar-mcp',
13
+ version: '1.0.0',
14
+ }, {
15
+ capabilities: {
16
+ tools: {},
17
+ },
18
+ });
19
+ // List available tools
20
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
21
+ return {
22
+ tools: [
23
+ {
24
+ name: 'calendar_create_event',
25
+ description: 'Create a new calendar event. Supports natural language dates like "tomorrow at 2pm" or "next Tuesday".',
26
+ inputSchema: {
27
+ type: 'object',
28
+ properties: {
29
+ title: {
30
+ type: 'string',
31
+ description: 'Event title/summary (required)',
32
+ },
33
+ start: {
34
+ type: 'string',
35
+ description: 'Start date/time - ISO 8601 format or natural language (required)',
36
+ },
37
+ end: {
38
+ type: 'string',
39
+ description: 'End date/time - ISO 8601 format or natural language (optional, defaults to start + 1 hour)',
40
+ },
41
+ location: {
42
+ type: 'string',
43
+ description: 'Event location (optional)',
44
+ },
45
+ description: {
46
+ type: 'string',
47
+ description: 'Event description/notes (optional)',
48
+ },
49
+ allDay: {
50
+ type: 'boolean',
51
+ description: 'Whether this is an all-day event (optional, default: false)',
52
+ },
53
+ },
54
+ required: ['title', 'start'],
55
+ },
56
+ },
57
+ {
58
+ name: 'calendar_list_events',
59
+ description: 'List calendar events with optional filtering by date range.',
60
+ inputSchema: {
61
+ type: 'object',
62
+ properties: {
63
+ startDate: {
64
+ type: 'string',
65
+ description: 'Filter events starting after this date (optional)',
66
+ },
67
+ endDate: {
68
+ type: 'string',
69
+ description: 'Filter events ending before this date (optional)',
70
+ },
71
+ limit: {
72
+ type: 'number',
73
+ description: 'Maximum number of events to return (default: 50)',
74
+ },
75
+ sortOrder: {
76
+ type: 'string',
77
+ enum: ['asc', 'desc'],
78
+ description: 'Sort order by start date (default: asc)',
79
+ },
80
+ },
81
+ },
82
+ },
83
+ {
84
+ name: 'calendar_update_event',
85
+ description: 'Update an existing calendar event. Only provided fields will be updated.',
86
+ inputSchema: {
87
+ type: 'object',
88
+ properties: {
89
+ id: {
90
+ type: 'string',
91
+ description: 'Event ID to update (required)',
92
+ },
93
+ title: {
94
+ type: 'string',
95
+ description: 'New event title (optional)',
96
+ },
97
+ start: {
98
+ type: 'string',
99
+ description: 'New start date/time (optional)',
100
+ },
101
+ end: {
102
+ type: 'string',
103
+ description: 'New end date/time (optional)',
104
+ },
105
+ location: {
106
+ type: 'string',
107
+ description: 'New location (optional)',
108
+ },
109
+ description: {
110
+ type: 'string',
111
+ description: 'New description (optional)',
112
+ },
113
+ },
114
+ required: ['id'],
115
+ },
116
+ },
117
+ {
118
+ name: 'calendar_delete_event',
119
+ description: 'Delete a calendar event by ID.',
120
+ inputSchema: {
121
+ type: 'object',
122
+ properties: {
123
+ id: {
124
+ type: 'string',
125
+ description: 'Event ID to delete (required)',
126
+ },
127
+ },
128
+ required: ['id'],
129
+ },
130
+ },
131
+ {
132
+ name: 'calendar_find_free_time',
133
+ description: 'Find available time slots in the calendar that match the specified duration.',
134
+ inputSchema: {
135
+ type: 'object',
136
+ properties: {
137
+ duration: {
138
+ type: 'number',
139
+ description: 'Required duration in minutes',
140
+ },
141
+ startDate: {
142
+ type: 'string',
143
+ description: 'Start of search range - ISO 8601 or natural language',
144
+ },
145
+ endDate: {
146
+ type: 'string',
147
+ description: 'End of search range - ISO 8601 or natural language',
148
+ },
149
+ workingHoursOnly: {
150
+ type: 'boolean',
151
+ description: 'Only search during working hours 9am-5pm (default: true)',
152
+ },
153
+ maxResults: {
154
+ type: 'number',
155
+ description: 'Maximum number of slots to return (default: 5)',
156
+ },
157
+ },
158
+ required: ['duration', 'startDate', 'endDate'],
159
+ },
160
+ },
161
+ {
162
+ name: 'calendar_search_events',
163
+ description: 'Search for events by text, date range, or location.',
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ query: {
168
+ type: 'string',
169
+ description: 'Search text - searches title, description, and location',
170
+ },
171
+ startDate: {
172
+ type: 'string',
173
+ description: 'Filter events after this date (optional)',
174
+ },
175
+ endDate: {
176
+ type: 'string',
177
+ description: 'Filter events before this date (optional)',
178
+ },
179
+ location: {
180
+ type: 'string',
181
+ description: 'Filter by location (optional)',
182
+ },
183
+ limit: {
184
+ type: 'number',
185
+ description: 'Maximum results (default: 20)',
186
+ },
187
+ },
188
+ },
189
+ },
190
+ ],
191
+ };
192
+ });
193
+ // Handle tool calls
194
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
195
+ const { name, arguments: args } = request.params;
196
+ try {
197
+ switch (name) {
198
+ case 'calendar_create_event': {
199
+ const result = await createEvent(args);
200
+ return { content: [{ type: 'text', text: result }] };
201
+ }
202
+ case 'calendar_list_events': {
203
+ const result = await listEvents((args ?? {}));
204
+ return {
205
+ content: [
206
+ {
207
+ type: 'text',
208
+ text: JSON.stringify(result, null, 2),
209
+ },
210
+ ],
211
+ };
212
+ }
213
+ case 'calendar_update_event': {
214
+ const result = await updateEvent(args);
215
+ return { content: [{ type: 'text', text: result }] };
216
+ }
217
+ case 'calendar_delete_event': {
218
+ const result = await deleteEvent(args);
219
+ return { content: [{ type: 'text', text: result }] };
220
+ }
221
+ case 'calendar_find_free_time': {
222
+ const result = await findFreeTime(args);
223
+ return {
224
+ content: [
225
+ {
226
+ type: 'text',
227
+ text: JSON.stringify(result, null, 2),
228
+ },
229
+ ],
230
+ };
231
+ }
232
+ case 'calendar_search_events': {
233
+ const result = await searchEvents((args ?? {}));
234
+ return {
235
+ content: [
236
+ {
237
+ type: 'text',
238
+ text: JSON.stringify(result, null, 2),
239
+ },
240
+ ],
241
+ };
242
+ }
243
+ default:
244
+ throw new Error(`Unknown tool: ${name}`);
245
+ }
246
+ }
247
+ catch (error) {
248
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
249
+ return {
250
+ content: [{ type: 'text', text: `Error: ${errorMessage}` }],
251
+ isError: true,
252
+ };
253
+ }
254
+ });
255
+ // Start the server
256
+ async function main() {
257
+ const transport = new StdioServerTransport();
258
+ await server.connect(transport);
259
+ console.error('Calendar MCP server running');
260
+ }
261
+ main().catch(console.error);
@@ -0,0 +1,7 @@
1
+ import type { CreateEventParams } from '../types.js';
2
+ /**
3
+ * Create a new calendar event
4
+ * @param params - Event parameters
5
+ * @returns Success message with event details
6
+ */
7
+ export declare function createEvent(params: CreateEventParams): Promise<string>;
@@ -0,0 +1,57 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import { readCalendarFile, writeCalendarFile } from '../utils/ical.js';
3
+ import { parseNaturalDate, addHours, toISO, formatForDisplay } from '../utils/dates.js';
4
+ import { commitAndPush } from '../utils/git.js';
5
+ /**
6
+ * Create a new calendar event
7
+ * @param params - Event parameters
8
+ * @returns Success message with event details
9
+ */
10
+ export async function createEvent(params) {
11
+ // Validate required fields
12
+ if (!params.title || params.title.trim() === '') {
13
+ throw new Error('Event title is required');
14
+ }
15
+ if (!params.start) {
16
+ throw new Error('Event start date/time is required');
17
+ }
18
+ // Parse dates
19
+ const startDate = parseNaturalDate(params.start);
20
+ let endDate;
21
+ if (params.end) {
22
+ endDate = parseNaturalDate(params.end);
23
+ }
24
+ else if (!params.allDay) {
25
+ // Default to 1 hour duration for non-all-day events
26
+ endDate = addHours(startDate, 1);
27
+ }
28
+ // Validate end is after start
29
+ if (endDate && endDate <= startDate) {
30
+ throw new Error('End date/time must be after start date/time');
31
+ }
32
+ // Read existing events
33
+ const events = await readCalendarFile();
34
+ // Create new event
35
+ const now = new Date();
36
+ const newEvent = {
37
+ id: uuidv4(),
38
+ title: params.title.trim(),
39
+ start: toISO(startDate),
40
+ end: endDate ? toISO(endDate) : undefined,
41
+ location: params.location?.trim(),
42
+ description: params.description?.trim(),
43
+ allDay: params.allDay ?? false,
44
+ status: 'CONFIRMED',
45
+ created: toISO(now),
46
+ lastModified: toISO(now),
47
+ };
48
+ // Add to events list
49
+ events.push(newEvent);
50
+ // Write back to file
51
+ await writeCalendarFile(events);
52
+ // Commit changes
53
+ await commitAndPush(`Add event: ${newEvent.title}`);
54
+ // Return success message
55
+ const displayDate = formatForDisplay(startDate, newEvent.allDay);
56
+ return `Created event "${newEvent.title}" on ${displayDate}`;
57
+ }
@@ -0,0 +1,7 @@
1
+ import type { DeleteEventParams } from '../types.js';
2
+ /**
3
+ * Delete a calendar event
4
+ * @param params - Delete parameters (id required)
5
+ * @returns Success message
6
+ */
7
+ export declare function deleteEvent(params: DeleteEventParams): Promise<string>;
@@ -0,0 +1,29 @@
1
+ import { readCalendarFile, writeCalendarFile } from '../utils/ical.js';
2
+ import { commitAndPush } from '../utils/git.js';
3
+ /**
4
+ * Delete a calendar event
5
+ * @param params - Delete parameters (id required)
6
+ * @returns Success message
7
+ */
8
+ export async function deleteEvent(params) {
9
+ // Validate required field
10
+ if (!params.id) {
11
+ throw new Error('Event ID is required');
12
+ }
13
+ // Read existing events
14
+ const events = await readCalendarFile();
15
+ // Find the event to delete
16
+ const eventIndex = events.findIndex(e => e.id === params.id);
17
+ if (eventIndex === -1) {
18
+ throw new Error(`Event not found: ${params.id}`);
19
+ }
20
+ const deletedEvent = events[eventIndex];
21
+ // Remove from array
22
+ events.splice(eventIndex, 1);
23
+ // Write back to file
24
+ await writeCalendarFile(events);
25
+ // Commit changes
26
+ await commitAndPush(`Delete event: ${deletedEvent.title}`);
27
+ // Return success message
28
+ return `Deleted event "${deletedEvent.title}"`;
29
+ }
@@ -0,0 +1,7 @@
1
+ import type { FindFreeTimeParams, FindFreeTimeResult } from '../types.js';
2
+ /**
3
+ * Find available time slots in the calendar
4
+ * @param params - Search parameters
5
+ * @returns List of free time slots
6
+ */
7
+ export declare function findFreeTime(params: FindFreeTimeParams): Promise<FindFreeTimeResult>;
@@ -0,0 +1,119 @@
1
+ import { readCalendarFile } from '../utils/ical.js';
2
+ import { parseNaturalDate, addMinutes, toISO, rangesOverlap, } from '../utils/dates.js';
3
+ const DEFAULT_MAX_RESULTS = 5;
4
+ const WORKING_HOURS_START = 9; // 9 AM
5
+ const WORKING_HOURS_END = 17; // 5 PM
6
+ const SLOT_INCREMENT_MINUTES = 30; // Check every 30 minutes
7
+ /**
8
+ * Find available time slots in the calendar
9
+ * @param params - Search parameters
10
+ * @returns List of free time slots
11
+ */
12
+ export async function findFreeTime(params) {
13
+ // Validate required fields
14
+ if (!params.duration || params.duration <= 0) {
15
+ throw new Error('Duration must be a positive number of minutes');
16
+ }
17
+ if (!params.startDate) {
18
+ throw new Error('Start date is required');
19
+ }
20
+ if (!params.endDate) {
21
+ throw new Error('End date is required');
22
+ }
23
+ const searchStart = parseNaturalDate(params.startDate);
24
+ const searchEnd = parseNaturalDate(params.endDate);
25
+ if (searchEnd <= searchStart) {
26
+ throw new Error('End date must be after start date');
27
+ }
28
+ const workingHoursOnly = params.workingHoursOnly ?? true;
29
+ const maxResults = params.maxResults ?? DEFAULT_MAX_RESULTS;
30
+ // Read all events in the search range
31
+ const allEvents = await readCalendarFile();
32
+ // Filter events that overlap with search range
33
+ const events = allEvents.filter(event => {
34
+ const eventStart = new Date(event.start);
35
+ const eventEnd = event.end ? new Date(event.end) : addMinutes(eventStart, 60);
36
+ return rangesOverlap(eventStart, eventEnd, searchStart, searchEnd);
37
+ });
38
+ // Sort events by start time
39
+ events.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
40
+ // Find free slots
41
+ const freeSlots = [];
42
+ let currentTime = new Date(searchStart);
43
+ while (currentTime < searchEnd && freeSlots.length < maxResults) {
44
+ // Adjust for working hours if needed
45
+ if (workingHoursOnly) {
46
+ const adjustedTime = adjustForWorkingHours(currentTime);
47
+ if (!adjustedTime) {
48
+ // Move to next day
49
+ currentTime = getNextWorkingDayStart(currentTime);
50
+ continue;
51
+ }
52
+ currentTime = adjustedTime;
53
+ }
54
+ // Check if this slot is free
55
+ const slotEnd = addMinutes(currentTime, params.duration);
56
+ // Make sure slot end doesn't exceed working hours
57
+ if (workingHoursOnly) {
58
+ const dayEnd = getWorkingHoursEnd(currentTime);
59
+ if (slotEnd > dayEnd) {
60
+ // Move to next day
61
+ currentTime = getNextWorkingDayStart(currentTime);
62
+ continue;
63
+ }
64
+ }
65
+ // Make sure slot end doesn't exceed search range
66
+ if (slotEnd > searchEnd) {
67
+ break;
68
+ }
69
+ // Check for conflicts with existing events
70
+ const hasConflict = events.some(event => {
71
+ const eventStart = new Date(event.start);
72
+ const eventEnd = event.end ? new Date(event.end) : addMinutes(eventStart, 60);
73
+ return rangesOverlap(currentTime, slotEnd, eventStart, eventEnd);
74
+ });
75
+ if (!hasConflict) {
76
+ freeSlots.push({
77
+ start: toISO(currentTime),
78
+ end: toISO(slotEnd),
79
+ durationMinutes: params.duration,
80
+ });
81
+ // Skip past this slot
82
+ currentTime = slotEnd;
83
+ }
84
+ else {
85
+ // Move to next slot increment
86
+ currentTime = addMinutes(currentTime, SLOT_INCREMENT_MINUTES);
87
+ }
88
+ }
89
+ return { freeSlots };
90
+ }
91
+ /**
92
+ * Adjust a time to be within working hours
93
+ * Returns null if the time is after working hours for the day
94
+ */
95
+ function adjustForWorkingHours(time) {
96
+ const hours = time.getUTCHours();
97
+ if (hours < WORKING_HOURS_START) {
98
+ // Before working hours - move to start of working hours
99
+ return new Date(Date.UTC(time.getUTCFullYear(), time.getUTCMonth(), time.getUTCDate(), WORKING_HOURS_START, 0, 0, 0));
100
+ }
101
+ if (hours >= WORKING_HOURS_END) {
102
+ // After working hours - return null to trigger next day
103
+ return null;
104
+ }
105
+ return time;
106
+ }
107
+ /**
108
+ * Get the start of the next working day
109
+ */
110
+ function getNextWorkingDayStart(time) {
111
+ const nextDay = new Date(Date.UTC(time.getUTCFullYear(), time.getUTCMonth(), time.getUTCDate() + 1, WORKING_HOURS_START, 0, 0, 0));
112
+ return nextDay;
113
+ }
114
+ /**
115
+ * Get the end of working hours for a given day
116
+ */
117
+ function getWorkingHoursEnd(time) {
118
+ return new Date(Date.UTC(time.getUTCFullYear(), time.getUTCMonth(), time.getUTCDate(), WORKING_HOURS_END, 0, 0, 0));
119
+ }
@@ -0,0 +1,11 @@
1
+ import type { ListEventsParams, ListEventsResult, CalendarEvent } from '../types.js';
2
+ /**
3
+ * List calendar events with optional filtering
4
+ * @param params - Filter parameters
5
+ * @returns List of events with count and hasMore indicator
6
+ */
7
+ export declare function listEvents(params?: ListEventsParams): Promise<ListEventsResult>;
8
+ /**
9
+ * Get all events without filtering (internal use)
10
+ */
11
+ export declare function getAllEvents(): Promise<CalendarEvent[]>;