dav-mcp 3.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +260 -0
  4. package/package.json +80 -0
  5. package/src/error-handler.js +215 -0
  6. package/src/formatters.js +754 -0
  7. package/src/logger.js +144 -0
  8. package/src/server-http.js +402 -0
  9. package/src/server-stdio.js +225 -0
  10. package/src/tool-call-logger.js +148 -0
  11. package/src/tools/calendar/calendar-multi-get.js +38 -0
  12. package/src/tools/calendar/calendar-query.js +98 -0
  13. package/src/tools/calendar/create-event.js +79 -0
  14. package/src/tools/calendar/delete-calendar.js +36 -0
  15. package/src/tools/calendar/delete-event.js +38 -0
  16. package/src/tools/calendar/index.js +16 -0
  17. package/src/tools/calendar/list-calendars.js +21 -0
  18. package/src/tools/calendar/list-events.js +43 -0
  19. package/src/tools/calendar/make-calendar.js +80 -0
  20. package/src/tools/calendar/update-calendar.js +106 -0
  21. package/src/tools/calendar/update-event-fields.js +119 -0
  22. package/src/tools/calendar/update-event-raw.js +45 -0
  23. package/src/tools/contacts/addressbook-multi-get.js +38 -0
  24. package/src/tools/contacts/addressbook-query.js +85 -0
  25. package/src/tools/contacts/create-contact.js +84 -0
  26. package/src/tools/contacts/delete-contact.js +38 -0
  27. package/src/tools/contacts/index.js +13 -0
  28. package/src/tools/contacts/list-addressbooks.js +21 -0
  29. package/src/tools/contacts/list-contacts.js +32 -0
  30. package/src/tools/contacts/update-contact-fields.js +135 -0
  31. package/src/tools/contacts/update-contact-raw.js +45 -0
  32. package/src/tools/index.js +57 -0
  33. package/src/tools/shared/helpers.js +132 -0
  34. package/src/tools/todos/create-todo.js +101 -0
  35. package/src/tools/todos/delete-todo.js +38 -0
  36. package/src/tools/todos/index.js +12 -0
  37. package/src/tools/todos/list-todos.js +30 -0
  38. package/src/tools/todos/todo-multi-get.js +37 -0
  39. package/src/tools/todos/todo-query.js +112 -0
  40. package/src/tools/todos/update-todo-fields.js +119 -0
  41. package/src/tools/todos/update-todo-raw.js +46 -0
  42. package/src/tsdav-client.js +199 -0
  43. package/src/utils/tool-helpers.js +388 -0
  44. package/src/validation.js +245 -0
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Shared helper functions for tsdav MCP tools
3
+ * This module eliminates code duplication across calendar, contact, and todo operations
4
+ */
5
+
6
+ /**
7
+ * Escapes special XML characters to prevent injection attacks
8
+ * @param {string} text - Text to escape
9
+ * @returns {string} XML-safe string
10
+ */
11
+ function escapeXml(text) {
12
+ if (!text) return '';
13
+ return text
14
+ .replace(/&/g, '&')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;')
18
+ .replace(/'/g, '&apos;');
19
+ }
20
+
21
+ /**
22
+ * Validates and retrieves a calendar by URL
23
+ *
24
+ * @param {Object} client - The CalDAV client instance
25
+ * @param {string} calendarUrl - The URL of the calendar to find
26
+ * @returns {Promise<Object>} The validated calendar object
27
+ * @throws {Error} If calendar is not found with helpful error message
28
+ *
29
+ * @example
30
+ * const calendar = await getValidatedCalendar(client, 'https://dav.example.com/cal/user/work/');
31
+ */
32
+ export async function getValidatedCalendar(client, calendarUrl) {
33
+ const calendars = await client.fetchCalendars();
34
+ const calendar = calendars.find(c => c.url === calendarUrl);
35
+
36
+ if (!calendar) {
37
+ const availableUrls = calendars.map(c => c.url).join('\n- ');
38
+ throw new Error(
39
+ `Calendar not found: ${calendarUrl}\n\n` +
40
+ `Available calendar URLs:\n- ${availableUrls}\n\n` +
41
+ `Please use list_calendars first to get the correct calendar URLs.`
42
+ );
43
+ }
44
+
45
+ return calendar;
46
+ }
47
+
48
+ /**
49
+ * Validates and retrieves an address book by URL
50
+ *
51
+ * @param {Object} client - The CardDAV client instance
52
+ * @param {string} addressBookUrl - The URL of the address book to find
53
+ * @returns {Promise<Object>} The validated address book object
54
+ * @throws {Error} If address book is not found with helpful error message
55
+ *
56
+ * @example
57
+ * const addressBook = await getValidatedAddressBook(client, 'https://dav.example.com/card/user/contacts/');
58
+ */
59
+ export async function getValidatedAddressBook(client, addressBookUrl) {
60
+ const addressBooks = await client.fetchAddressBooks();
61
+ const addressBook = addressBooks.find(ab => ab.url === addressBookUrl);
62
+
63
+ if (!addressBook) {
64
+ const availableUrls = addressBooks.map(ab => ab.url).join('\n- ');
65
+ throw new Error(
66
+ `Address book not found: ${addressBookUrl}\n\n` +
67
+ `Available address book URLs:\n- ${availableUrls}\n\n` +
68
+ `Please use list_addressbooks first to get the correct address book URLs.`
69
+ );
70
+ }
71
+
72
+ return addressBook;
73
+ }
74
+
75
+ /**
76
+ * Builds time range options for CalDAV queries
77
+ * If only start date is provided, defaults end date to 1 year from start
78
+ *
79
+ * @param {string|undefined} startDate - Optional start date in ISO 8601 format
80
+ * @param {string|undefined} endDate - Optional end date in ISO 8601 format
81
+ * @returns {Object} Options object with timeRange property (or empty object if no dates)
82
+ *
83
+ * @example
84
+ * // Both dates provided
85
+ * buildTimeRangeOptions('2025-01-01T00:00:00.000Z', '2025-12-31T23:59:59.000Z')
86
+ * // Returns: { timeRange: { start: '2025-01-01...', end: '2025-12-31...' } }
87
+ *
88
+ * // Only start date (end = start + 1 year)
89
+ * buildTimeRangeOptions('2025-01-01T00:00:00.000Z', undefined)
90
+ * // Returns: { timeRange: { start: '2025-01-01...', end: '2026-01-01...' } }
91
+ *
92
+ * // No dates
93
+ * buildTimeRangeOptions(undefined, undefined)
94
+ * // Returns: {}
95
+ */
96
+ export function buildTimeRangeOptions(startDate, endDate) {
97
+ // No time range specified
98
+ if (!startDate) {
99
+ return {};
100
+ }
101
+
102
+ // Only start date provided - default end to 1 year from start
103
+ if (!endDate) {
104
+ const start = new Date(startDate);
105
+ const end = new Date(start);
106
+ end.setFullYear(end.getFullYear() + 1);
107
+
108
+ return {
109
+ timeRange: {
110
+ start: startDate,
111
+ end: end.toISOString(),
112
+ },
113
+ };
114
+ }
115
+
116
+ // Both dates provided
117
+ return {
118
+ timeRange: {
119
+ start: startDate,
120
+ end: endDate,
121
+ },
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Searches multiple calendars and aggregates results with calendar name annotations
127
+ *
128
+ * @param {Object} client - The CalDAV client instance
129
+ * @param {Array<Object>} calendars - Array of calendar objects to search
130
+ * @param {Object} fetchOptions - Options to pass to fetchCalendarObjects (e.g., timeRange)
131
+ * @returns {Promise<Array>} All events/todos from all calendars with _calendarName property
132
+ *
133
+ * @example
134
+ * const calendars = [cal1, cal2, cal3];
135
+ * const events = await searchMultipleCalendars(client, calendars, { timeRange: {...} });
136
+ * // Each event has event._calendarName = "Work Calendar" etc.
137
+ */
138
+ export async function searchMultipleCalendars(client, calendars, fetchOptions = {}) {
139
+ let allItems = [];
140
+
141
+ for (const calendar of calendars) {
142
+ const options = { calendar, ...fetchOptions };
143
+ const items = await client.fetchCalendarObjects(options);
144
+
145
+ // Add calendar info to each item for display
146
+ items.forEach(item => {
147
+ item._calendarName = calendar.displayName || calendar.url;
148
+ });
149
+
150
+ allItems = allItems.concat(items);
151
+ }
152
+
153
+ return allItems;
154
+ }
155
+
156
+ /**
157
+ * Searches multiple calendars for todos and aggregates results
158
+ *
159
+ * @param {Object} client - The CalDAV client instance
160
+ * @param {Array<Object>} calendars - Array of calendar objects to search
161
+ * @returns {Promise<Array>} All todos from all calendars with _calendarName property
162
+ *
163
+ * @example
164
+ * const calendars = [cal1, cal2];
165
+ * const todos = await searchMultipleTodoCalendars(client, calendars);
166
+ */
167
+ export async function searchMultipleTodoCalendars(client, calendars) {
168
+ let allTodos = [];
169
+
170
+ for (const calendar of calendars) {
171
+ const todos = await client.fetchTodos({ calendar });
172
+
173
+ // Add calendar info to each todo
174
+ todos.forEach(todo => {
175
+ todo._calendarName = calendar.displayName || calendar.url;
176
+ });
177
+
178
+ allTodos = allTodos.concat(todos);
179
+ }
180
+
181
+ return allTodos;
182
+ }
183
+
184
+ /**
185
+ * Builds WebDAV PROPPATCH XML for updating calendar properties
186
+ * Uses proper XML escaping to prevent injection attacks
187
+ *
188
+ * @param {Object} properties - Object with optional display_name, description, color, timezone
189
+ * @returns {string} Complete PROPPATCH XML string
190
+ * @throws {Error} If timezone format is invalid
191
+ *
192
+ * @example
193
+ * const xml = buildPropPatchXml({
194
+ * display_name: 'My Calendar',
195
+ * description: 'Work & Projects',
196
+ * color: '#FF5733',
197
+ * timezone: 'Europe/Berlin'
198
+ * });
199
+ */
200
+ export function buildPropPatchXml(properties) {
201
+ const { display_name, description, color, timezone } = properties;
202
+
203
+ // Validate timezone format if provided
204
+ if (timezone && !timezone.includes('/')) {
205
+ throw new Error(
206
+ `Invalid timezone format: ${timezone}. ` +
207
+ `Expected format: "Europe/Berlin", "America/New_York", etc.`
208
+ );
209
+ }
210
+
211
+ let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
212
+ xml += '<d:propertyupdate xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:x="http://apple.com/ns/ical/">\n';
213
+ xml += ' <d:set>\n';
214
+ xml += ' <d:prop>\n';
215
+
216
+ if (display_name) {
217
+ xml += ` <d:displayname>${escapeXml(display_name)}</d:displayname>\n`;
218
+ }
219
+ if (description) {
220
+ xml += ` <c:calendar-description>${escapeXml(description)}</c:calendar-description>\n`;
221
+ }
222
+ if (color) {
223
+ xml += ` <x:calendar-color>${escapeXml(color)}</x:calendar-color>\n`;
224
+ }
225
+ if (timezone) {
226
+ xml += ` <c:calendar-timezone>${escapeXml(timezone)}</c:calendar-timezone>\n`;
227
+ }
228
+
229
+ xml += ' </d:prop>\n';
230
+ xml += ' </d:set>\n';
231
+ xml += '</d:propertyupdate>';
232
+
233
+ return xml;
234
+ }
235
+
236
+ /**
237
+ * Generic filter function for events, contacts, or todos
238
+ * Applies multiple filters with case-insensitive substring matching
239
+ *
240
+ * @param {Array<Object>} items - Array of items to filter (events/contacts/todos)
241
+ * @param {Object} filters - Object with filter values
242
+ * @param {Object} extractors - Object mapping filter keys to regex extractors
243
+ * @returns {Array<Object>} Filtered items
244
+ *
245
+ * @example
246
+ * // Filter events by summary and location
247
+ * const filtered = applyFilters(
248
+ * events,
249
+ * { summary_filter: 'meeting', location_filter: 'room' },
250
+ * {
251
+ * summary_filter: /SUMMARY:(.+)/,
252
+ * location_filter: /LOCATION:(.+)/
253
+ * }
254
+ * );
255
+ */
256
+ export function applyFilters(items, filters, extractors) {
257
+ let filtered = items;
258
+
259
+ for (const [filterKey, filterValue] of Object.entries(filters)) {
260
+ if (!filterValue || !extractors[filterKey]) continue;
261
+
262
+ const regex = extractors[filterKey];
263
+ const searchLower = filterValue.toLowerCase();
264
+
265
+ filtered = filtered.filter(item => {
266
+ const match = item.data?.match(regex);
267
+ const value = match?.[1] || '';
268
+ return value.toLowerCase().includes(searchLower);
269
+ });
270
+ }
271
+
272
+ return filtered;
273
+ }
274
+
275
+ /**
276
+ * Resolves which calendars to search based on optional calendar_url parameter
277
+ * If calendar_url is provided, validates and returns single calendar in array
278
+ * Otherwise returns all calendars
279
+ *
280
+ * @param {Object} client - The CalDAV client instance
281
+ * @param {string|undefined} calendarUrl - Optional specific calendar URL
282
+ * @returns {Promise<Array<Object>>} Array of calendars to search
283
+ * @throws {Error} If specific calendar not found
284
+ *
285
+ * @example
286
+ * // Search specific calendar
287
+ * const calendars = await resolveCalendarsToSearch(client, 'https://...');
288
+ * // Returns: [specificCalendar]
289
+ *
290
+ * // Search all calendars
291
+ * const calendars = await resolveCalendarsToSearch(client, undefined);
292
+ * // Returns: [cal1, cal2, cal3, ...]
293
+ */
294
+ export async function resolveCalendarsToSearch(client, calendarUrl) {
295
+ const calendars = await client.fetchCalendars();
296
+
297
+ // Search all calendars if no specific URL provided
298
+ if (!calendarUrl) {
299
+ return calendars;
300
+ }
301
+
302
+ // Find specific calendar
303
+ const calendar = calendars.find(c => c.url === calendarUrl);
304
+
305
+ if (!calendar) {
306
+ const availableUrls = calendars.map(c => c.url).join('\n- ');
307
+ throw new Error(
308
+ `Calendar not found: ${calendarUrl}\n\n` +
309
+ `Available calendar URLs:\n- ${availableUrls}\n\n` +
310
+ `Tip: Omit calendar_url to search across all calendars automatically.`
311
+ );
312
+ }
313
+
314
+ return [calendar];
315
+ }
316
+
317
+ /**
318
+ * Generates display name for single or multi-calendar searches
319
+ *
320
+ * @param {Array<Object>} calendars - Array of calendars that were searched
321
+ * @returns {string} Display name for formatter
322
+ *
323
+ * @example
324
+ * getCalendarDisplayName([cal1]) // Returns: "Work Calendar"
325
+ * getCalendarDisplayName([cal1, cal2, cal3]) // Returns: "All Calendars (3)"
326
+ */
327
+ export function getCalendarDisplayName(calendars) {
328
+ if (calendars.length === 1) {
329
+ return calendars[0].displayName || calendars[0].url;
330
+ }
331
+ return `All Calendars (${calendars.length})`;
332
+ }
333
+
334
+ /**
335
+ * Resolves which address books to search based on optional URL
336
+ *
337
+ * @param {Object} client - The DAV client
338
+ * @param {string} [addressbookUrl] - Optional specific address book URL
339
+ * @returns {Promise<Array<Object>>} Array of address books to search
340
+ *
341
+ * @example
342
+ * // Search specific address book
343
+ * const addressbooks = await resolveAddressBooksToSearch(client, 'http://example.com/addressbook');
344
+ * // Returns: [addressbook1]
345
+ *
346
+ * // Search all address books
347
+ * const addressbooks = await resolveAddressBooksToSearch(client, undefined);
348
+ * // Returns: [addressbook1, addressbook2, addressbook3, ...]
349
+ */
350
+ export async function resolveAddressBooksToSearch(client, addressbookUrl) {
351
+ const addressbooks = await client.fetchAddressBooks();
352
+
353
+ // Search all address books if no specific URL provided
354
+ if (!addressbookUrl) {
355
+ return addressbooks;
356
+ }
357
+
358
+ // Find specific address book
359
+ const addressbook = addressbooks.find(a => a.url === addressbookUrl);
360
+
361
+ if (!addressbook) {
362
+ const availableUrls = addressbooks.map(a => a.url).join('\n- ');
363
+ throw new Error(
364
+ `Address book not found: ${addressbookUrl}\n\n` +
365
+ `Available address book URLs:\n- ${availableUrls}\n\n` +
366
+ `Tip: Omit addressbook_url to search across all address books automatically.`
367
+ );
368
+ }
369
+
370
+ return [addressbook];
371
+ }
372
+
373
+ /**
374
+ * Generates display name for single or multi-addressbook searches
375
+ *
376
+ * @param {Array<Object>} addressbooks - Array of address books that were searched
377
+ * @returns {string} Display name for formatter
378
+ *
379
+ * @example
380
+ * getAddressBookDisplayName([book1]) // Returns: "Personal Contacts"
381
+ * getAddressBookDisplayName([book1, book2, book3]) // Returns: "All Address Books (3)"
382
+ */
383
+ export function getAddressBookDisplayName(addressbooks) {
384
+ if (addressbooks.length === 1) {
385
+ return addressbooks[0].displayName || addressbooks[0].url;
386
+ }
387
+ return `All Address Books (${addressbooks.length})`;
388
+ }
@@ -0,0 +1,245 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Validation schemas for all MCP tools
5
+ */
6
+
7
+ // Helper: DateTime string with optional timezone offset
8
+ // Accepts both "2026-03-02T09:00:00Z" and "2026-03-02T09:00:00"
9
+ const dateTimeWithOptionalOffset = z.union([
10
+ z.string().datetime({ offset: true }), // With timezone (Z or +00:00)
11
+ z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/, 'Invalid datetime format') // Without timezone
12
+ ]);
13
+
14
+ // Helper: Optional URL that gracefully handles LLM placeholder values
15
+ // Transforms common LLM-generated placeholders ("", "unknown", "default", etc.) to undefined
16
+ const optionalUrl = (message) =>
17
+ z.preprocess(
18
+ (val) => {
19
+ // Transform common LLM placeholder values to undefined
20
+ if (!val ||
21
+ val === '' ||
22
+ val === 'null' ||
23
+ val === 'undefined' ||
24
+ val === 'unknown' ||
25
+ val === 'default' ||
26
+ val === 'none' ||
27
+ val === 'N/A' ||
28
+ val === 'n/a') {
29
+ return undefined;
30
+ }
31
+ return val;
32
+ },
33
+ z.string().url(message).optional()
34
+ );
35
+
36
+ // CalDAV Schemas
37
+ export const listCalendarsSchema = z.object({});
38
+
39
+ export const listEventsSchema = z.object({
40
+ calendar_url: optionalUrl('Invalid calendar URL'),
41
+ time_range_start: dateTimeWithOptionalOffset.optional(),
42
+ time_range_end: dateTimeWithOptionalOffset.optional(),
43
+ });
44
+
45
+ export const createEventSchema = z.object({
46
+ calendar_url: z.string().url('Invalid calendar URL'),
47
+ summary: z.string().min(1, 'Summary is required').max(500),
48
+ start_date: dateTimeWithOptionalOffset,
49
+ end_date: dateTimeWithOptionalOffset,
50
+ description: z.string().max(5000).optional(),
51
+ location: z.string().max(500).optional(),
52
+ }).refine((data) => new Date(data.end_date) > new Date(data.start_date), {
53
+ message: 'End date must be after start date',
54
+ path: ['end_date'],
55
+ });
56
+
57
+ export const updateEventSchema = z.object({
58
+ event_url: z.string().url('Invalid event URL'),
59
+ event_etag: z.string().min(1, 'ETag is required'),
60
+ updated_ical_data: z.string().min(1, 'iCal data is required'),
61
+ });
62
+
63
+ export const deleteEventSchema = z.object({
64
+ event_url: z.string().url('Invalid event URL'),
65
+ event_etag: z.string().min(1, 'ETag is required'),
66
+ });
67
+
68
+ export const calendarQuerySchema = z.object({
69
+ calendar_url: optionalUrl('Invalid calendar URL'),
70
+ time_range_start: dateTimeWithOptionalOffset.optional(),
71
+ time_range_end: dateTimeWithOptionalOffset.optional(),
72
+ summary_filter: z.string().optional(),
73
+ location_filter: z.string().optional(),
74
+ }).refine((data) => {
75
+ // Rule 1: If ANY time field used, BOTH must be present
76
+ if (data.time_range_start || data.time_range_end) {
77
+ return data.time_range_start && data.time_range_end;
78
+ }
79
+
80
+ // Rule 2: At least ONE filter type must exist
81
+ return !!(data.calendar_url ||
82
+ data.summary_filter ||
83
+ data.location_filter);
84
+ }, {
85
+ message: "Provide: (time_range with BOTH dates) OR (text filter) OR (both)"
86
+ });
87
+
88
+ export const makeCalendarSchema = z.object({
89
+ display_name: z.string().min(1, 'Display name is required').max(200),
90
+ description: z.string().max(500).optional(),
91
+ color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
92
+ timezone: z.string().optional(),
93
+ components: z.array(z.enum(['VEVENT', 'VTODO', 'VJOURNAL'])).optional(),
94
+ });
95
+
96
+ export const updateCalendarSchema = z.object({
97
+ calendar_url: z.string().url('Invalid calendar URL'),
98
+ display_name: z.string().min(1).max(200).optional(),
99
+ description: z.string().max(500).optional(),
100
+ color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
101
+ timezone: z.string().optional(),
102
+ }).refine(data => {
103
+ // At least one field must be provided for update
104
+ return data.display_name || data.description || data.color || data.timezone;
105
+ }, {
106
+ message: 'At least one field (display_name, description, color, or timezone) must be provided for update',
107
+ });
108
+
109
+ export const deleteCalendarSchema = z.object({
110
+ calendar_url: z.string().url('Invalid calendar URL'),
111
+ });
112
+
113
+ export const calendarMultiGetSchema = z.object({
114
+ calendar_url: z.string().url('Invalid calendar URL'),
115
+ event_urls: z.array(z.string().url('Invalid event URL')).min(1, 'At least one event URL required'),
116
+ });
117
+
118
+ // CardDAV Schemas
119
+ export const listAddressbooksSchema = z.object({});
120
+
121
+ export const listContactsSchema = z.object({
122
+ addressbook_url: z.string().url('Invalid addressbook URL'),
123
+ });
124
+
125
+ export const createContactSchema = z.object({
126
+ addressbook_url: z.string().url('Invalid addressbook URL'),
127
+ full_name: z.string().min(1, 'Full name is required').max(200),
128
+ family_name: z.string().max(100).optional(),
129
+ given_name: z.string().max(100).optional(),
130
+ email: z.string().email('Invalid email format').optional(),
131
+ phone: z.string().max(50).optional(),
132
+ organization: z.string().max(200).optional(),
133
+ note: z.string().max(1000).optional(),
134
+ });
135
+
136
+ export const updateContactSchema = z.object({
137
+ vcard_url: z.string().url('Invalid vCard URL'),
138
+ vcard_etag: z.string().min(1, 'ETag is required'),
139
+ updated_vcard_data: z.string().min(1, 'vCard data is required'),
140
+ });
141
+
142
+ export const deleteContactSchema = z.object({
143
+ vcard_url: z.string().url('Invalid vCard URL'),
144
+ vcard_etag: z.string().min(1, 'ETag is required'),
145
+ });
146
+
147
+ export const addressBookQuerySchema = z.object({
148
+ addressbook_url: optionalUrl('Invalid addressbook URL'),
149
+ name_filter: z.string().optional(),
150
+ email_filter: z.string().optional(),
151
+ organization_filter: z.string().optional(),
152
+ }).refine((data) => {
153
+ // At least one filter required
154
+ return !!(data.name_filter ||
155
+ data.email_filter ||
156
+ data.organization_filter);
157
+ }, {
158
+ message: "At least one filter required: name_filter, email_filter, or organization_filter"
159
+ });
160
+
161
+ export const addressBookMultiGetSchema = z.object({
162
+ addressbook_url: z.string().url('Invalid addressbook URL'),
163
+ contact_urls: z.array(z.string().url('Invalid contact URL')).min(1, 'At least one contact URL required'),
164
+ });
165
+
166
+ // VTODO (Task) Schemas
167
+ export const listTodosSchema = z.object({
168
+ calendar_url: z.string().url('Invalid calendar URL'),
169
+ });
170
+
171
+ export const createTodoSchema = z.object({
172
+ calendar_url: z.string().url('Invalid calendar URL'),
173
+ summary: z.string().min(1, 'Summary is required').max(500),
174
+ description: z.string().max(5000).optional(),
175
+ due_date: z.string().optional(), // ISO 8601 with timezone
176
+ priority: z.number().int().min(0).max(9).optional(), // 0=undefined, 1=highest, 9=lowest
177
+ status: z.enum(['NEEDS-ACTION', 'IN-PROCESS', 'COMPLETED', 'CANCELLED']).optional(),
178
+ percent_complete: z.number().int().min(0).max(100).optional(),
179
+ });
180
+
181
+ export const updateTodoSchema = z.object({
182
+ todo_url: z.string().url('Invalid todo URL'),
183
+ todo_etag: z.string().min(1, 'ETag is required'),
184
+ updated_ical_data: z.string().min(1, 'iCal data is required'),
185
+ });
186
+
187
+ export const deleteTodoSchema = z.object({
188
+ todo_url: z.string().url('Invalid todo URL'),
189
+ todo_etag: z.string().min(1, 'ETag is required'),
190
+ });
191
+
192
+ export const todoQuerySchema = z.object({
193
+ calendar_url: optionalUrl('Invalid calendar URL'),
194
+ summary_filter: z.string().optional(),
195
+ status_filter: z.enum(['NEEDS-ACTION', 'IN-PROCESS', 'COMPLETED', 'CANCELLED']).optional(),
196
+ time_range_start: dateTimeWithOptionalOffset.optional(),
197
+ time_range_end: dateTimeWithOptionalOffset.optional(),
198
+ }).refine((data) => {
199
+ // Rule 1: If ANY time field used, BOTH must be present
200
+ if (data.time_range_start || data.time_range_end) {
201
+ return data.time_range_start && data.time_range_end;
202
+ }
203
+
204
+ // Rule 2: At least ONE filter type must exist
205
+ return !!(data.calendar_url ||
206
+ data.summary_filter ||
207
+ data.status_filter);
208
+ }, {
209
+ message: "Provide: (time_range with BOTH dates) OR (text/status filter) OR (both)"
210
+ });
211
+
212
+ export const todoMultiGetSchema = z.object({
213
+ todo_urls: z.array(z.string().url('Invalid todo URL')).min(1, 'At least one todo URL required'),
214
+ });
215
+
216
+ /**
217
+ * Validate input against a schema
218
+ */
219
+ export function validateInput(schema, data) {
220
+ const result = schema.safeParse(data);
221
+ if (!result.success) {
222
+ const errors = result.error.errors.map(err => `${err.path.join('.')}: ${err.message}`).join(', ');
223
+ throw new Error(`Validation failed: ${errors}`);
224
+ }
225
+ return result.data;
226
+ }
227
+
228
+ /**
229
+ * Sanitize string for iCal/vCard format (escape special characters)
230
+ */
231
+ export function sanitizeICalString(str) {
232
+ if (!str) return '';
233
+ return str
234
+ .replace(/\\/g, '\\\\') // Escape backslashes
235
+ .replace(/;/g, '\\;') // Escape semicolons
236
+ .replace(/,/g, '\\,') // Escape commas
237
+ .replace(/\n/g, '\\n'); // Escape newlines
238
+ }
239
+
240
+ /**
241
+ * Sanitize vCard string
242
+ */
243
+ export function sanitizeVCardString(str) {
244
+ return sanitizeICalString(str);
245
+ }