1use rmcp::{
9 ErrorData as McpError, RoleServer, ServiceExt, handler::server::wrapper::Parameters, model::*,
10 prompt, prompt_handler, prompt_router, schemars, schemars::JsonSchema, service::RequestContext,
11 tool, tool_handler, tool_router, transport::stdio,
12};
13use serde::{Deserialize, Serialize};
14
15use crate::{AuthorizationStatus, EventsManager, RemindersManager};
16use chrono::{DateTime, Duration, Local, NaiveDateTime, TimeZone};
17
18use rmcp::handler::server::wrapper::Json;
19
20fn mcp_err(e: &crate::EventKitError) -> McpError {
29 use crate::EventKitError::*;
30 let msg = match e {
31 AuthorizationDenied => {
32 "Reminders/Calendar access denied. Open System Settings → Privacy & Security \
33 and enable access for `eventkit`. If `eventkit` is not listed, run \
34 `tccutil reset Reminders` (or Calendar) in a terminal and retry. \
35 Call `auth_status` to see the current state."
36 .to_string()
37 }
38 AuthorizationRestricted => {
39 "Reminders/Calendar access is restricted by system policy (MDM or parental controls)."
40 .to_string()
41 }
42 AuthorizationNotDetermined => {
43 "Authorization is undetermined and the consent dialog did not fire. \
44 The binary may be missing Info.plist usage strings — rebuild and retry. \
45 Call `auth_status` to see the current state."
46 .to_string()
47 }
48 AuthorizationRequestFailed(detail) => {
49 format!("Authorization request failed: {detail}")
50 }
51 _ => e.to_string(),
52 };
53 McpError::internal_error(msg, None)
54}
55
56fn mcp_invalid(msg: impl std::fmt::Display) -> McpError {
57 McpError::invalid_params(msg.to_string(), None)
58}
59
60#[derive(Serialize, JsonSchema)]
61struct ListResponse<T: Serialize> {
62 #[schemars(with = "i64")]
63 count: usize,
64 items: Vec<T>,
65}
66
67#[derive(Serialize, JsonSchema)]
68struct DeletedResponse {
69 id: String,
70}
71
72#[derive(Serialize, JsonSchema)]
73struct BatchResponse {
74 #[schemars(with = "i64")]
75 total: usize,
76 #[schemars(with = "i64")]
77 succeeded: usize,
78 #[schemars(with = "i64")]
79 failed: usize,
80 #[serde(skip_serializing_if = "Vec::is_empty")]
81 errors: Vec<BatchItemError>,
82}
83
84#[derive(Serialize, JsonSchema)]
85struct BatchItemError {
86 item_id: String,
87 message: String,
88}
89
90#[derive(Serialize, JsonSchema)]
91struct SearchResponse {
92 query: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 reminders: Option<ListResponse<ReminderOutput>>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 events: Option<ListResponse<EventOutput>>,
97}
98
99#[derive(Serialize, JsonSchema)]
100struct CoordinateOutput {
101 latitude: f64,
102 longitude: f64,
103}
104
105#[derive(Serialize, JsonSchema)]
106struct AuthStatusOutput {
107 reminders: &'static str,
109 events: &'static str,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 remediation: Option<String>,
114}
115
116#[derive(Debug, Serialize, Deserialize, JsonSchema)]
117#[serde(rename_all = "lowercase")]
118pub enum AccessEntity {
119 Reminder,
120 Event,
121}
122
123#[derive(Debug, Serialize, Deserialize, JsonSchema)]
124pub struct RequestAccessRequest {
125 pub entity: AccessEntity,
127}
128
129#[derive(Serialize, JsonSchema)]
130struct RequestAccessOutput {
131 granted: bool,
132 status: &'static str,
135}
136
137#[derive(Debug, Serialize, Deserialize, JsonSchema)]
138pub struct SetReminderDueTimezoneRequest {
139 pub reminder_id: String,
140 pub timezone: Option<String>,
142}
143
144#[derive(Debug, Serialize, Deserialize, JsonSchema)]
145#[serde(rename_all = "lowercase")]
146pub enum GeofenceProximity {
147 Enter,
148 Leave,
149}
150
151#[derive(Debug, Serialize, Deserialize, JsonSchema)]
152pub struct GeofenceInput {
153 pub title: String,
154 pub latitude: f64,
155 pub longitude: f64,
156 pub radius_meters: f64,
158 pub proximity: GeofenceProximity,
160}
161
162#[derive(Debug, Serialize, Deserialize, JsonSchema)]
163pub struct SetReminderGeofenceRequest {
164 pub reminder_id: String,
165 pub geofence: Option<GeofenceInput>,
168}
169
170#[derive(Debug, Serialize, Deserialize, JsonSchema)]
174pub struct StructuredLocationInput {
175 pub title: String,
176 pub latitude: f64,
177 pub longitude: f64,
178 pub radius_meters: f64,
180}
181
182#[derive(Debug, Serialize, Deserialize, JsonSchema)]
183pub struct SetEventAvailabilityRequest {
184 pub event_id: String,
185 pub availability: String,
187}
188
189fn parse_availability(s: &str) -> Result<crate::EventAvailability, String> {
192 match s {
193 "busy" => Ok(crate::EventAvailability::Busy),
194 "free" => Ok(crate::EventAvailability::Free),
195 "tentative" => Ok(crate::EventAvailability::Tentative),
196 "unavailable" => Ok(crate::EventAvailability::Unavailable),
197 "not_supported" => Ok(crate::EventAvailability::NotSupported),
198 other => Err(format!(
199 "invalid availability '{other}'. Use busy, free, tentative, unavailable, or not_supported."
200 )),
201 }
202}
203
204fn parse_span(s: Option<&str>) -> Result<crate::EventSpan, String> {
207 match s {
208 None | Some("this") => Ok(crate::EventSpan::This),
209 Some("future") => Ok(crate::EventSpan::Future),
210 Some(other) => Err(format!(
211 "invalid span '{other}'. Use \"this\" or \"future\"."
212 )),
213 }
214}
215
216fn auth_status_str(s: AuthorizationStatus) -> &'static str {
217 match s {
218 AuthorizationStatus::NotDetermined => "NotDetermined",
219 AuthorizationStatus::Restricted => "Restricted",
220 AuthorizationStatus::Denied => "Denied",
221 AuthorizationStatus::FullAccess => "FullAccess",
222 AuthorizationStatus::WriteOnly => "WriteOnly",
223 }
224}
225
226fn auth_remediation(reminders: AuthorizationStatus, events: AuthorizationStatus) -> Option<String> {
227 let granted = |s| {
228 matches!(
229 s,
230 AuthorizationStatus::FullAccess | AuthorizationStatus::WriteOnly
231 )
232 };
233 if granted(reminders) && granted(events) {
234 return None;
235 }
236 let worst = |s| match s {
237 AuthorizationStatus::Denied => 3,
238 AuthorizationStatus::Restricted => 2,
239 AuthorizationStatus::NotDetermined => 1,
240 _ => 0,
241 };
242 let pick = if worst(reminders) >= worst(events) {
243 reminders
244 } else {
245 events
246 };
247 Some(match pick {
248 AuthorizationStatus::NotDetermined => {
249 "Call any reminders or calendar tool to trigger the macOS consent dialog. \
250 If no dialog appears, the binary is missing its Info.plist usage strings — \
251 rebuild from a version that embeds them."
252 .into()
253 }
254 AuthorizationStatus::Denied => {
255 "Open System Settings → Privacy & Security → Reminders (and/or Calendar) and \
256 enable access for `eventkit`. If `eventkit` is not listed, run \
257 `tccutil reset Reminders` (or `tccutil reset Calendar`) in a terminal and \
258 retry — that clears the cached denial so the consent dialog can fire again."
259 .into()
260 }
261 AuthorizationStatus::Restricted => {
262 "Access is blocked by an MDM or parental-controls policy. The user cannot \
263 override this from System Settings — an administrator must change the policy."
264 .into()
265 }
266 _ => "Authorization is partially granted; one of reminders/events is not in FullAccess/WriteOnly state.".into(),
267 })
268}
269
270#[derive(Serialize, JsonSchema)]
271struct LocationOutput {
272 title: String,
273 latitude: f64,
274 longitude: f64,
275 radius_meters: f64,
276}
277
278#[derive(Serialize, JsonSchema)]
279struct AlarmOutput {
280 #[serde(skip_serializing_if = "Option::is_none")]
281 relative_offset_seconds: Option<f64>,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 absolute_date: Option<String>,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 proximity: Option<String>,
286 #[serde(skip_serializing_if = "Option::is_none")]
287 location: Option<LocationOutput>,
288 #[serde(skip_serializing_if = "Option::is_none")]
289 email_address: Option<String>,
290 #[serde(skip_serializing_if = "Option::is_none")]
291 sound_name: Option<String>,
292 #[serde(skip_serializing_if = "Option::is_none")]
293 url: Option<String>,
294 alarm_type: String,
297}
298
299impl AlarmOutput {
300 fn from_info(a: &crate::AlarmInfo) -> Self {
301 Self {
302 relative_offset_seconds: a.relative_offset,
303 absolute_date: a.absolute_date.map(|d| d.to_rfc3339()),
304 proximity: match a.proximity {
305 crate::AlarmProximity::Enter => Some("enter".into()),
306 crate::AlarmProximity::Leave => Some("leave".into()),
307 crate::AlarmProximity::None => None,
308 },
309 location: a.location.as_ref().map(|l| LocationOutput {
310 title: l.title.clone(),
311 latitude: l.latitude,
312 longitude: l.longitude,
313 radius_meters: l.radius,
314 }),
315 email_address: a.email_address.clone(),
316 sound_name: a.sound_name.clone(),
317 url: a.url.clone(),
318 alarm_type: match a.alarm_type {
319 crate::AlarmType::Display => "display",
320 crate::AlarmType::Audio => "audio",
321 crate::AlarmType::Procedure => "procedure",
322 crate::AlarmType::Email => "email",
323 crate::AlarmType::Unknown => "unknown",
324 }
325 .into(),
326 }
327 }
328}
329
330#[derive(Serialize, JsonSchema)]
331struct RecurrenceRuleOutput {
332 frequency: String,
333 #[schemars(with = "i64")]
334 interval: usize,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 #[schemars(with = "Option<Vec<i32>>")]
337 days_of_week: Option<Vec<u8>>,
338 #[serde(skip_serializing_if = "Option::is_none")]
339 days_of_month: Option<Vec<i32>>,
340 #[serde(skip_serializing_if = "Option::is_none")]
341 months_of_year: Option<Vec<i32>>,
342 #[serde(skip_serializing_if = "Option::is_none")]
343 weeks_of_year: Option<Vec<i32>>,
344 #[serde(skip_serializing_if = "Option::is_none")]
345 days_of_year: Option<Vec<i32>>,
346 #[serde(skip_serializing_if = "Option::is_none")]
347 set_positions: Option<Vec<i32>>,
348 end: RecurrenceEndOutput,
349}
350
351#[derive(Serialize, JsonSchema)]
352#[serde(tag = "type", rename_all = "snake_case")]
353enum RecurrenceEndOutput {
354 Never,
355 AfterCount {
356 #[schemars(with = "i64")]
357 count: usize,
358 },
359 OnDate {
360 date: String,
361 },
362}
363
364impl RecurrenceRuleOutput {
365 fn from_rule(r: &crate::RecurrenceRule) -> Self {
366 Self {
367 frequency: match r.frequency {
368 crate::RecurrenceFrequency::Daily => "daily",
369 crate::RecurrenceFrequency::Weekly => "weekly",
370 crate::RecurrenceFrequency::Monthly => "monthly",
371 crate::RecurrenceFrequency::Yearly => "yearly",
372 }
373 .into(),
374 interval: r.interval,
375 days_of_week: r.days_of_week.clone(),
376 days_of_month: r.days_of_month.clone(),
377 months_of_year: r.months_of_year.clone(),
378 weeks_of_year: r.weeks_of_year.clone(),
379 days_of_year: r.days_of_year.clone(),
380 set_positions: r.set_positions.clone(),
381 end: match &r.end {
382 crate::RecurrenceEndCondition::Never => RecurrenceEndOutput::Never,
383 crate::RecurrenceEndCondition::AfterCount(n) => {
384 RecurrenceEndOutput::AfterCount { count: *n }
385 }
386 crate::RecurrenceEndCondition::OnDate(d) => RecurrenceEndOutput::OnDate {
387 date: d.to_rfc3339(),
388 },
389 },
390 }
391 }
392}
393
394#[derive(Serialize, JsonSchema)]
395struct AttendeeOutput {
396 #[serde(skip_serializing_if = "Option::is_none")]
397 name: Option<String>,
398 role: String,
399 status: String,
400 is_current_user: bool,
401}
402
403impl AttendeeOutput {
404 fn from_info(p: &crate::ParticipantInfo) -> Self {
405 Self {
406 name: p.name.clone(),
407 role: format!("{:?}", p.role).to_lowercase(),
408 status: format!("{:?}", p.status).to_lowercase(),
409 is_current_user: p.is_current_user,
410 }
411 }
412}
413
414#[derive(Serialize, JsonSchema)]
415#[allow(non_snake_case)]
416struct ReminderOutput {
417 id: String,
418 title: String,
419 completed: bool,
420 priority: String,
421 #[serde(skip_serializing_if = "Option::is_none")]
422 list_name: Option<String>,
423 #[serde(skip_serializing_if = "Option::is_none")]
424 list_id: Option<String>,
425 #[serde(skip_serializing_if = "Option::is_none")]
426 due_date: Option<String>,
427 #[serde(skip_serializing_if = "Option::is_none")]
428 start_date: Option<String>,
429 #[serde(skip_serializing_if = "Option::is_none")]
430 completion_date: Option<String>,
431 #[serde(skip_serializing_if = "Option::is_none")]
432 notes: Option<String>,
433 #[serde(skip_serializing_if = "Option::is_none")]
434 URL: Option<String>,
435 #[serde(skip_serializing_if = "Option::is_none")]
436 location: Option<String>,
437 #[serde(skip_serializing_if = "Option::is_none")]
441 due_date_timezone: Option<String>,
442 #[serde(skip_serializing_if = "Option::is_none")]
444 geofence: Option<LocationOutput>,
445 #[serde(skip_serializing_if = "Option::is_none")]
447 parent_id: Option<String>,
448 #[serde(skip_serializing_if = "is_zero")]
449 attachments_count: usize,
450 #[serde(skip_serializing_if = "Vec::is_empty")]
451 alarms: Vec<AlarmOutput>,
452 #[serde(skip_serializing_if = "Vec::is_empty")]
453 recurrence_rules: Vec<RecurrenceRuleOutput>,
454}
455
456fn is_zero(n: &usize) -> bool {
457 *n == 0
458}
459
460impl ReminderOutput {
461 fn from_item(r: &crate::ReminderItem, manager: &RemindersManager) -> Self {
462 let alarms = if r.has_alarms {
463 manager
464 .get_alarms(&r.identifier)
465 .unwrap_or_default()
466 .iter()
467 .map(AlarmOutput::from_info)
468 .collect()
469 } else {
470 vec![]
471 };
472 let recurrence_rules = if r.has_recurrence_rules {
473 manager
474 .get_recurrence_rules(&r.identifier)
475 .unwrap_or_default()
476 .iter()
477 .map(RecurrenceRuleOutput::from_rule)
478 .collect()
479 } else {
480 vec![]
481 };
482 Self {
483 alarms,
484 recurrence_rules,
485 ..Self::from_item_summary(r)
486 }
487 }
488
489 fn from_item_summary(r: &crate::ReminderItem) -> Self {
490 Self {
491 id: r.identifier.clone(),
492 title: r.title.clone(),
493 completed: r.completed,
494 priority: Priority::label(r.priority).into(),
495 list_name: r.calendar_title.clone(),
496 list_id: r.calendar_id.clone(),
497 due_date: r.due_date.map(|d| d.to_rfc3339()),
498 start_date: r.start_date.map(|d| d.to_rfc3339()),
499 completion_date: r.completion_date.map(|d| d.to_rfc3339()),
500 notes: r.notes.clone(),
501 URL: r.URL.clone(),
502 location: r.location.clone(),
503 due_date_timezone: r.due_date_timezone.clone(),
504 geofence: r.structured_location.as_ref().map(|s| LocationOutput {
505 title: s.title.clone(),
506 latitude: s.latitude,
507 longitude: s.longitude,
508 radius_meters: s.radius,
509 }),
510 parent_id: r.parent_id.clone(),
511 attachments_count: r.attachments_count,
512 alarms: vec![],
513 recurrence_rules: vec![],
514 }
515 }
516}
517
518#[derive(Serialize, JsonSchema)]
519#[allow(non_snake_case)]
520struct EventOutput {
521 id: String,
522 title: String,
523 start: String,
524 end: String,
525 all_day: bool,
526 #[serde(skip_serializing_if = "Option::is_none")]
527 calendar_name: Option<String>,
528 #[serde(skip_serializing_if = "Option::is_none")]
529 calendar_id: Option<String>,
530 #[serde(skip_serializing_if = "Option::is_none")]
531 notes: Option<String>,
532 #[serde(skip_serializing_if = "Option::is_none")]
533 location: Option<String>,
534 #[serde(skip_serializing_if = "Option::is_none")]
535 URL: Option<String>,
536 availability: String,
537 status: String,
538 #[serde(skip_serializing_if = "Option::is_none")]
539 structured_location: Option<LocationOutput>,
540 #[serde(skip_serializing_if = "Vec::is_empty")]
541 alarms: Vec<AlarmOutput>,
542 #[serde(skip_serializing_if = "Vec::is_empty")]
543 recurrence_rules: Vec<RecurrenceRuleOutput>,
544 #[serde(skip_serializing_if = "Vec::is_empty")]
545 attendees: Vec<AttendeeOutput>,
546 #[serde(skip_serializing_if = "Option::is_none")]
547 organizer: Option<AttendeeOutput>,
548 is_detached: bool,
549 #[serde(skip_serializing_if = "Option::is_none")]
550 occurrence_date: Option<String>,
551 #[serde(skip_serializing_if = "Option::is_none")]
552 creation_date: Option<String>,
553 #[serde(skip_serializing_if = "Option::is_none")]
554 last_modified_date: Option<String>,
555 #[serde(skip_serializing_if = "Option::is_none")]
556 external_identifier: Option<String>,
557 #[serde(skip_serializing_if = "Option::is_none")]
560 timezone: Option<String>,
561 #[serde(skip_serializing_if = "is_zero")]
562 attachments_count: usize,
563}
564
565impl EventOutput {
566 fn from_item(e: &crate::EventItem, manager: &EventsManager) -> Self {
567 let alarms = manager
568 .get_event_alarms(&e.identifier)
569 .unwrap_or_default()
570 .iter()
571 .map(AlarmOutput::from_info)
572 .collect();
573 let recurrence_rules = manager
574 .get_event_recurrence_rules(&e.identifier)
575 .unwrap_or_default()
576 .iter()
577 .map(RecurrenceRuleOutput::from_rule)
578 .collect();
579 Self {
580 alarms,
581 recurrence_rules,
582 ..Self::from_item_summary(e)
583 }
584 }
585
586 fn from_item_summary(e: &crate::EventItem) -> Self {
587 Self {
588 id: e.identifier.clone(),
589 title: e.title.clone(),
590 start: e.start_date.to_rfc3339(),
591 end: e.end_date.to_rfc3339(),
592 all_day: e.all_day,
593 calendar_name: e.calendar_title.clone(),
594 calendar_id: e.calendar_id.clone(),
595 notes: e.notes.clone(),
596 location: e.location.clone(),
597 URL: e.URL.clone(),
598 availability: match e.availability {
599 crate::EventAvailability::Busy => "busy",
600 crate::EventAvailability::Free => "free",
601 crate::EventAvailability::Tentative => "tentative",
602 crate::EventAvailability::Unavailable => "unavailable",
603 _ => "not_supported",
604 }
605 .into(),
606 status: match e.status {
607 crate::EventStatus::Confirmed => "confirmed",
608 crate::EventStatus::Tentative => "tentative",
609 crate::EventStatus::Canceled => "canceled",
610 _ => "none",
611 }
612 .into(),
613 structured_location: e.structured_location.as_ref().map(|l| LocationOutput {
614 title: l.title.clone(),
615 latitude: l.latitude,
616 longitude: l.longitude,
617 radius_meters: l.radius,
618 }),
619 alarms: vec![],
620 recurrence_rules: vec![],
621 attendees: e.attendees.iter().map(AttendeeOutput::from_info).collect(),
622 organizer: e.organizer.as_ref().map(AttendeeOutput::from_info),
623 is_detached: e.is_detached,
624 occurrence_date: e.occurrence_date.map(|d| d.to_rfc3339()),
625 creation_date: e.creation_date.map(|d| d.to_rfc3339()),
626 last_modified_date: e.last_modified_date.map(|d| d.to_rfc3339()),
627 external_identifier: e.external_identifier.clone(),
628 timezone: e.timezone.clone(),
629 attachments_count: e.attachments_count,
630 }
631 }
632}
633
634#[derive(Serialize, JsonSchema)]
635struct CalendarOutput {
636 id: String,
637 title: String,
638 color: String,
639 #[serde(skip_serializing_if = "Option::is_none")]
640 source: Option<String>,
641 #[serde(skip_serializing_if = "Option::is_none")]
642 source_id: Option<String>,
643 allows_modifications: bool,
644 is_immutable: bool,
645 is_subscribed: bool,
646 entity_types: Vec<String>,
647 #[serde(skip_serializing_if = "Vec::is_empty")]
652 supported_event_availabilities: Vec<String>,
653}
654
655impl CalendarOutput {
656 fn from_info(c: &crate::CalendarInfo) -> Self {
657 Self {
658 id: c.identifier.clone(),
659 title: c.title.clone(),
660 color: c
661 .color
662 .map(|(r, g, b, _)| CalendarColor::from_rgba(r, g, b).to_string())
663 .unwrap_or_else(|| "none".into()),
664 source: c.source.clone(),
665 source_id: c.source_id.clone(),
666 allows_modifications: c.allows_modifications,
667 is_immutable: c.is_immutable,
668 is_subscribed: c.is_subscribed,
669 entity_types: c.allowed_entity_types.clone(),
670 supported_event_availabilities: c.supported_event_availabilities.clone(),
671 }
672 }
673}
674
675#[derive(Serialize, JsonSchema)]
676struct SourceOutput {
677 id: String,
678 title: String,
679 source_type: String,
680}
681
682impl SourceOutput {
683 fn from_info(s: &crate::SourceInfo) -> Self {
684 Self {
685 id: s.identifier.clone(),
686 title: s.title.clone(),
687 source_type: s.source_type.clone(),
688 }
689 }
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
698#[serde(rename_all = "lowercase")]
699pub enum Priority {
700 None,
702 Low,
704 Medium,
706 High,
708}
709
710impl Priority {
711 fn to_usize(&self) -> usize {
712 match self {
713 Priority::None => 0,
714 Priority::Low => 9,
715 Priority::Medium => 5,
716 Priority::High => 1,
717 }
718 }
719
720 fn label(v: usize) -> &'static str {
721 match v {
722 1..=4 => "high",
723 5 => "medium",
724 6..=9 => "low",
725 _ => "none",
726 }
727 }
728}
729
730#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
732#[serde(rename_all = "lowercase")]
733pub enum ItemType {
734 Reminder,
735 Event,
736}
737
738#[derive(Debug, Serialize, Deserialize, JsonSchema)]
744pub struct AlarmParam {
745 pub relative_offset: Option<f64>,
747 pub proximity: Option<String>,
749 pub location_title: Option<String>,
751 pub latitude: Option<f64>,
753 pub longitude: Option<f64>,
755 pub radius: Option<f64>,
757 pub email_address: Option<String>,
760 pub sound_name: Option<String>,
763 pub url: Option<String>,
767}
768
769#[derive(Debug, Serialize, Deserialize, JsonSchema)]
771pub struct RecurrenceParam {
772 pub frequency: String,
774 #[serde(default = "default_interval")]
776 #[schemars(with = "i64")]
777 pub interval: usize,
778 #[schemars(with = "Option<Vec<i32>>")]
780 pub days_of_week: Option<Vec<u8>>,
781 pub days_of_month: Option<Vec<i32>>,
783 pub months_of_year: Option<Vec<i32>>,
785 pub weeks_of_year: Option<Vec<i32>>,
787 pub days_of_year: Option<Vec<i32>>,
789 pub set_positions: Option<Vec<i32>>,
793 #[schemars(with = "Option<i64>")]
795 pub end_after_count: Option<usize>,
796 pub end_date: Option<String>,
798}
799
800#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
805pub struct ListRemindersRequest {
806 #[serde(default)]
810 pub show_completed: bool,
811 pub list_name: Option<String>,
813 pub due_after: Option<String>,
816 pub due_before: Option<String>,
819 pub completed_after: Option<String>,
822 pub completed_before: Option<String>,
825}
826
827#[derive(Debug, Serialize, Deserialize, JsonSchema)]
828#[allow(non_snake_case)]
829pub struct CreateReminderRequest {
830 pub title: String,
832 pub list_name: String,
834 pub notes: Option<String>,
836 pub priority: Option<Priority>,
838 pub due_date: Option<String>,
840 pub start_date: Option<String>,
842 pub due_date_timezone: Option<String>,
846 pub geofence: Option<GeofenceInput>,
853 pub alarms: Option<Vec<AlarmParam>>,
855 pub recurrence: Option<RecurrenceParam>,
857}
858
859#[derive(Debug, Serialize, Deserialize, JsonSchema)]
860#[allow(non_snake_case)]
861pub struct UpdateReminderRequest {
862 pub reminder_id: String,
864 pub list_name: Option<String>,
866 pub title: Option<String>,
868 pub notes: Option<String>,
870 pub completed: Option<bool>,
872 pub priority: Option<Priority>,
874 pub due_date: Option<String>,
876 pub start_date: Option<String>,
878 pub due_date_timezone: Option<String>,
881 pub completion_date: Option<String>,
888 pub alarms: Option<Vec<AlarmParam>>,
890 pub recurrence: Option<RecurrenceParam>,
892}
893
894#[derive(Debug, Serialize, Deserialize, JsonSchema)]
895pub struct CreateReminderListRequest {
896 pub name: String,
898}
899
900#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
902#[serde(rename_all = "lowercase")]
903pub enum CalendarColor {
904 Red,
905 Orange,
906 Yellow,
907 Green,
908 Blue,
909 Purple,
910 Brown,
911 Pink,
912 Teal,
913}
914
915impl CalendarColor {
916 fn to_rgba(&self) -> (f64, f64, f64, f64) {
917 match self {
918 CalendarColor::Red => (1.0, 0.231, 0.188, 1.0),
919 CalendarColor::Orange => (1.0, 0.584, 0.0, 1.0),
920 CalendarColor::Yellow => (1.0, 0.8, 0.0, 1.0),
921 CalendarColor::Green => (0.298, 0.851, 0.392, 1.0),
922 CalendarColor::Blue => (0.0, 0.478, 1.0, 1.0),
923 CalendarColor::Purple => (0.686, 0.322, 0.871, 1.0),
924 CalendarColor::Brown => (0.635, 0.518, 0.369, 1.0),
925 CalendarColor::Pink => (1.0, 0.176, 0.333, 1.0),
926 CalendarColor::Teal => (0.353, 0.784, 0.98, 1.0),
927 }
928 }
929
930 fn from_rgba(r: f64, g: f64, b: f64) -> &'static str {
932 let colors: &[(&str, (f64, f64, f64))] = &[
933 ("red", (1.0, 0.231, 0.188)),
934 ("orange", (1.0, 0.584, 0.0)),
935 ("yellow", (1.0, 0.8, 0.0)),
936 ("green", (0.298, 0.851, 0.392)),
937 ("blue", (0.0, 0.478, 1.0)),
938 ("purple", (0.686, 0.322, 0.871)),
939 ("brown", (0.635, 0.518, 0.369)),
940 ("pink", (1.0, 0.176, 0.333)),
941 ("teal", (0.353, 0.784, 0.98)),
942 ];
943 colors
944 .iter()
945 .min_by(|(_, a), (_, b_)| {
946 let da = (a.0 - r).powi(2) + (a.1 - g).powi(2) + (a.2 - b).powi(2);
947 let db = (b_.0 - r).powi(2) + (b_.1 - g).powi(2) + (b_.2 - b).powi(2);
948 da.partial_cmp(&db).unwrap()
949 })
950 .map(|(name, _)| *name)
951 .unwrap_or("blue")
952 }
953}
954
955#[derive(Debug, Serialize, Deserialize, JsonSchema)]
956pub struct UpdateReminderListRequest {
957 pub list_id: String,
959 pub name: Option<String>,
961 pub color: Option<CalendarColor>,
963}
964
965#[derive(Debug, Serialize, Deserialize, JsonSchema)]
966pub struct UpdateEventCalendarRequest {
967 pub calendar_id: String,
969 pub name: Option<String>,
971 pub color: Option<CalendarColor>,
973}
974
975#[derive(Debug, Serialize, Deserialize, JsonSchema)]
976pub struct DeleteReminderListRequest {
977 pub list_id: String,
979}
980
981#[derive(Debug, Serialize, Deserialize, JsonSchema)]
982pub struct ReminderIdRequest {
983 pub reminder_id: String,
985}
986
987#[derive(Debug, Serialize, Deserialize, JsonSchema)]
988pub struct ListEventsRequest {
989 #[serde(default = "default_days")]
991 pub days: i64,
992 pub calendar_id: Option<String>,
994}
995
996fn default_days() -> i64 {
997 1
998}
999
1000#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1001#[allow(non_snake_case)]
1002pub struct CreateEventRequest {
1003 pub title: String,
1005 pub start: String,
1007 pub end: Option<String>,
1009 #[serde(default = "default_duration")]
1011 pub duration_minutes: i64,
1012 pub notes: Option<String>,
1014 pub location: Option<String>,
1016 pub calendar_name: Option<String>,
1018 #[serde(default)]
1020 pub all_day: bool,
1021 pub URL: Option<String>,
1023 pub availability: Option<String>,
1026 pub structured_location: Option<StructuredLocationInput>,
1029 pub alarms: Option<Vec<AlarmParam>>,
1031 pub recurrence: Option<RecurrenceParam>,
1033}
1034
1035fn default_duration() -> i64 {
1036 60
1037}
1038
1039#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1040pub struct EventIdRequest {
1041 pub event_id: String,
1043 pub span: Option<String>,
1046 #[serde(default)]
1049 pub affect_future: bool,
1050}
1051
1052#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1053#[allow(non_snake_case)]
1054pub struct UpdateEventRequest {
1055 pub event_id: String,
1057 pub title: Option<String>,
1059 pub notes: Option<String>,
1061 pub location: Option<String>,
1063 pub start: Option<String>,
1065 pub end: Option<String>,
1067 pub all_day: Option<bool>,
1069 pub calendar_name: Option<String>,
1071 pub URL: Option<String>,
1073 pub availability: Option<String>,
1075 pub structured_location: Option<StructuredLocationInput>,
1077 pub span: Option<String>,
1080 pub alarms: Option<Vec<AlarmParam>>,
1082 pub recurrence: Option<RecurrenceParam>,
1084}
1085
1086#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1091pub struct BatchDeleteRequest {
1092 pub item_type: ItemType,
1094 pub item_ids: Vec<String>,
1096 #[serde(default)]
1098 pub affect_future: bool,
1099}
1100
1101#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1102pub struct BatchMoveRequest {
1103 pub reminder_ids: Vec<String>,
1105 pub destination_list_name: String,
1107}
1108
1109#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1110pub struct BatchUpdateItem {
1111 pub item_id: String,
1113 pub title: Option<String>,
1115 pub notes: Option<String>,
1117 pub completed: Option<bool>,
1119 pub priority: Option<Priority>,
1121 pub due_date: Option<String>,
1123}
1124
1125#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1126pub struct BatchUpdateRequest {
1127 pub item_type: ItemType,
1129 pub updates: Vec<BatchUpdateItem>,
1131}
1132
1133#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1134pub struct SearchRequest {
1135 pub query: String,
1137 pub item_type: Option<ItemType>,
1139 #[serde(default)]
1141 pub include_completed: bool,
1142 #[serde(default = "default_search_days")]
1144 pub days: i64,
1145}
1146
1147fn default_search_days() -> i64 {
1148 30
1149}
1150
1151fn default_interval() -> usize {
1152 1
1153}
1154
1155#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1160pub struct ListRemindersPromptArgs {
1161 #[serde(default)]
1163 pub list_name: Option<String>,
1164}
1165
1166#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1167pub struct MoveReminderPromptArgs {
1168 pub reminder_id: String,
1170 pub destination_list: String,
1172}
1173
1174#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1175pub struct CreateReminderPromptArgs {
1176 pub title: String,
1178 #[serde(default)]
1180 pub notes: Option<String>,
1181 #[serde(default)]
1183 pub list_name: Option<String>,
1184 #[serde(default)]
1186 #[schemars(with = "Option<i32>")]
1187 pub priority: Option<u8>,
1188 #[serde(default)]
1190 pub due_date: Option<String>,
1191}
1192
1193pub struct EventKitServer {}
1207
1208impl Default for EventKitServer {
1209 fn default() -> Self {
1210 Self::new()
1211 }
1212}
1213
1214fn parse_datetime(s: &str) -> Result<DateTime<Local>, String> {
1216 if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M") {
1218 return Local
1219 .from_local_datetime(&dt)
1220 .single()
1221 .ok_or_else(|| "Invalid local datetime".to_string());
1222 }
1223
1224 if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
1226 let dt = date
1227 .and_hms_opt(0, 0, 0)
1228 .ok_or_else(|| "Invalid date".to_string())?;
1229 return Local
1230 .from_local_datetime(&dt)
1231 .single()
1232 .ok_or_else(|| "Invalid local datetime".to_string());
1233 }
1234
1235 Err("Invalid date format. Use 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM'".to_string())
1236}
1237
1238#[tool_router]
1239#[allow(non_snake_case)]
1240impl EventKitServer {
1241 pub fn new() -> Self {
1242 Self {}
1243 }
1244
1245 #[tool(
1250 description = "Check macOS permission status for Reminders and Calendar without requesting access. Use this to diagnose authorization problems before calling other tools — it never triggers a consent dialog."
1251 )]
1252 async fn auth_status(&self) -> Result<Json<AuthStatusOutput>, McpError> {
1253 let reminders = RemindersManager::authorization_status();
1254 let events = EventsManager::authorization_status();
1255 Ok(Json(AuthStatusOutput {
1256 reminders: auth_status_str(reminders),
1257 events: auth_status_str(events),
1258 remediation: auth_remediation(reminders, events),
1259 }))
1260 }
1261
1262 #[tool(
1263 description = "Trigger the macOS consent dialog for Reminders or Calendar access. The first call shows the system prompt; subsequent calls return the cached status. Blocks until the user responds. Use `entity` = \"reminder\" or \"event\"."
1264 )]
1265 async fn request_access(
1266 &self,
1267 Parameters(params): Parameters<RequestAccessRequest>,
1268 ) -> Result<Json<RequestAccessOutput>, McpError> {
1269 match params.entity {
1270 AccessEntity::Reminder => {
1271 let manager = RemindersManager::new();
1272 let granted = manager.request_access().map_err(|e| mcp_err(&e))?;
1273 Ok(Json(RequestAccessOutput {
1274 granted,
1275 status: auth_status_str(RemindersManager::authorization_status()),
1276 }))
1277 }
1278 AccessEntity::Event => {
1279 let manager = EventsManager::new();
1280 let granted = manager.request_access().map_err(|e| mcp_err(&e))?;
1281 Ok(Json(RequestAccessOutput {
1282 granted,
1283 status: auth_status_str(EventsManager::authorization_status()),
1284 }))
1285 }
1286 }
1287 }
1288
1289 #[tool(description = "List all reminder lists (calendars) available in macOS Reminders.")]
1294 async fn list_reminder_lists(&self) -> Result<Json<ListResponse<CalendarOutput>>, McpError> {
1295 let manager = RemindersManager::new();
1296 match manager.list_calendars() {
1297 Ok(lists) => {
1298 let items: Vec<_> = lists.iter().map(CalendarOutput::from_info).collect();
1299 Ok(Json(ListResponse {
1300 count: items.len(),
1301 items,
1302 }))
1303 }
1304 Err(e) => Err(mcp_err(&e)),
1305 }
1306 }
1307
1308 #[tool(
1309 description = "List reminders from macOS Reminders app. Filters: `show_completed` toggles inclusion of completed items; `list_name` restricts to one list; `due_after`/`due_before` window incomplete reminders by their due date; `completed_after`/`completed_before` window completed reminders by their completion date. When any `completed_*` filter is supplied, results are completed-only regardless of `show_completed`."
1310 )]
1311 async fn list_reminders(
1312 &self,
1313 Parameters(params): Parameters<ListRemindersRequest>,
1314 ) -> Result<Json<ListResponse<ReminderOutput>>, McpError> {
1315 let manager = RemindersManager::new();
1316
1317 fn parse_opt_date(
1318 label: &str,
1319 s: &Option<String>,
1320 ) -> Result<Option<DateTime<Local>>, McpError> {
1321 s.as_deref()
1322 .map(parse_datetime_or_time)
1323 .transpose()
1324 .map_err(|e| mcp_invalid(format!("Error parsing {label}: {e}")))
1325 }
1326 let due_after = parse_opt_date("due_after", ¶ms.due_after)?;
1327 let due_before = parse_opt_date("due_before", ¶ms.due_before)?;
1328 let completed_after = parse_opt_date("completed_after", ¶ms.completed_after)?;
1329 let completed_before = parse_opt_date("completed_before", ¶ms.completed_before)?;
1330
1331 let list_name_owned = params.list_name.clone();
1333 let calendar_titles: Option<Vec<&str>> = list_name_owned.as_ref().map(|n| vec![n.as_str()]);
1334 let calendar_titles_ref: Option<&[&str]> = calendar_titles.as_deref();
1335
1336 let reminders = if completed_after.is_some() || completed_before.is_some() {
1337 manager.fetch_completed_reminders_in_range(
1338 completed_after,
1339 completed_before,
1340 calendar_titles_ref,
1341 )
1342 } else if due_after.is_some() || due_before.is_some() {
1343 manager.fetch_incomplete_reminders_in_due_range(
1344 due_after,
1345 due_before,
1346 calendar_titles_ref,
1347 )
1348 } else if params.show_completed {
1349 manager.fetch_reminders(calendar_titles_ref)
1354 } else {
1355 manager.fetch_incomplete_reminders_in_due_range(None, None, calendar_titles_ref)
1356 };
1357
1358 match reminders {
1359 Ok(items) => {
1360 let items: Vec<_> = items
1361 .iter()
1362 .map(ReminderOutput::from_item_summary)
1363 .collect();
1364 Ok(Json(ListResponse {
1365 count: items.len(),
1366 items,
1367 }))
1368 }
1369 Err(e) => Err(mcp_err(&e)),
1370 }
1371 }
1372
1373 #[tool(
1374 description = "Create a new reminder in macOS Reminders. You MUST specify which list to add it to (use list_reminder_lists first to see available lists). Inline configuration: alarms (time-based or proximity-based via `geofence`), recurrence, due/start dates, IANA timezone for the due date. NB: `URL`, free-text `location`, and `structured_location` are intentionally absent on the reminder surface — iCloud Reminders silently drops those mutations. Use `set_reminder_geofence` for location-based reminders (the iCloud-honored path); for events those fields are first-class via `create_event`. Tags (the Reminders.app Tag Sidebar) are an iCloud server-side feature not reachable through EventKit at all."
1375 )]
1376 async fn create_reminder(
1377 &self,
1378 Parameters(params): Parameters<CreateReminderRequest>,
1379 ) -> Result<Json<ReminderOutput>, McpError> {
1380 let manager = RemindersManager::new();
1381
1382 let calendar_title = match manager.list_calendars() {
1384 Ok(lists) => {
1385 if let Some(cal) = lists.iter().find(|c| c.title == params.list_name) {
1386 cal.title.clone()
1387 } else {
1388 let available: Vec<_> = lists.iter().map(|c| c.title.as_str()).collect();
1389 return Err(mcp_invalid(format!(
1390 "List '{}' not found. Available lists: {}",
1391 params.list_name,
1392 available.join(", ")
1393 )));
1394 }
1395 }
1396 Err(e) => {
1397 return Err(mcp_invalid(format!("Error listing calendars: {e}")));
1398 }
1399 };
1400
1401 let due_date = match params
1402 .due_date
1403 .as_deref()
1404 .map(parse_datetime_or_time)
1405 .transpose()
1406 {
1407 Ok(v) => v,
1408 Err(e) => return Err(mcp_invalid(format!("Error parsing due_date: {e}"))),
1409 };
1410 let start_date = match params.start_date.as_deref().map(parse_datetime).transpose() {
1411 Ok(v) => v,
1412 Err(e) => return Err(mcp_invalid(format!("Error parsing start_date: {e}"))),
1413 };
1414
1415 let priority = params.priority.as_ref().map(Priority::to_usize);
1416
1417 match manager.create_reminder(&crate::ReminderDraft {
1418 title: ¶ms.title,
1419 notes: params.notes.as_deref(),
1420 calendar_title: Some(&calendar_title),
1421 priority,
1422 due_date,
1423 start_date,
1424 due_date_timezone: params.due_date_timezone.as_deref(),
1425 ..Default::default()
1426 }) {
1427 Ok(reminder) => {
1428 let id = reminder.identifier.clone();
1429 if let Some(alarms) = ¶ms.alarms {
1430 apply_alarms_reminder(&manager, &id, alarms);
1431 }
1432 if let Some(g) = ¶ms.geofence {
1433 let proximity = match g.proximity {
1434 GeofenceProximity::Enter => crate::AlarmProximity::Enter,
1435 GeofenceProximity::Leave => crate::AlarmProximity::Leave,
1436 };
1437 let sl = crate::StructuredLocation {
1438 title: g.title.clone(),
1439 latitude: g.latitude,
1440 longitude: g.longitude,
1441 radius: g.radius_meters,
1442 };
1443 if let Err(e) = manager.set_geofence(&id, Some((&sl, proximity))) {
1444 return Err(mcp_err(&e));
1445 }
1446 }
1447 if let Some(recurrence) = ¶ms.recurrence
1448 && let Ok(rule) = parse_recurrence_param(recurrence)
1449 {
1450 let _ = manager.set_recurrence_rule(&id, &rule);
1451 }
1452 let updated = manager.get_reminder(&id).unwrap_or(reminder);
1453 Ok(Json(ReminderOutput::from_item(&updated, &manager)))
1454 }
1455 Err(e) => Err(mcp_err(&e)),
1456 }
1457 }
1458
1459 #[tool(
1460 description = "Update an existing reminder. All fields are optional; only the ones you supply are written. Inline edits: title, notes, completed, priority, due/start date (empty string clears), due-date IANA timezone (empty string clears), completion_date (empty string clears and marks incomplete), alarms (replaces all when supplied), recurrence, list move. NB: `URL`/`location`/`structured_location` are absent — see `create_reminder`."
1461 )]
1462 async fn update_reminder(
1463 &self,
1464 Parameters(params): Parameters<UpdateReminderRequest>,
1465 ) -> Result<Json<ReminderOutput>, McpError> {
1466 let manager = RemindersManager::new();
1467
1468 let due_date = match ¶ms.due_date {
1470 Some(due_str) if due_str.is_empty() => Some(None),
1471 Some(due_str) => match parse_datetime_or_time(due_str) {
1472 Ok(dt) => Some(Some(dt)),
1473 Err(e) => return Err(mcp_invalid(format!("Error parsing due_date: {e}"))),
1474 },
1475 None => None,
1476 };
1477
1478 let start_date = match ¶ms.start_date {
1479 Some(start_str) if start_str.is_empty() => Some(None),
1480 Some(start_str) => match parse_datetime(start_str) {
1481 Ok(dt) => Some(Some(dt)),
1482 Err(e) => return Err(mcp_invalid(format!("Error parsing start_date: {e}"))),
1483 },
1484 None => None,
1485 };
1486
1487 if let Some(ref list_name) = params.list_name {
1488 match manager.list_calendars() {
1489 Ok(lists) => {
1490 if !lists.iter().any(|c| &c.title == list_name) {
1491 let available: Vec<_> = lists.iter().map(|c| c.title.as_str()).collect();
1492 return Err(mcp_invalid(format!(
1493 "List '{}' not found. Available lists: {}",
1494 list_name,
1495 available.join(", ")
1496 )));
1497 }
1498 }
1499 Err(e) => return Err(mcp_invalid(format!("Error: {e}"))),
1500 }
1501 }
1502
1503 let priority = params.priority.as_ref().map(Priority::to_usize);
1504
1505 fn opt_patch(s: &Option<String>) -> Option<Option<&str>> {
1508 s.as_ref()
1509 .map(|v| if v.is_empty() { None } else { Some(v.as_str()) })
1510 }
1511 let tz_patch = opt_patch(¶ms.due_date_timezone);
1512
1513 let completion_date_patch: Option<Option<DateTime<Local>>> = match ¶ms.completion_date {
1515 None => None,
1516 Some(s) if s.is_empty() => Some(None),
1517 Some(s) => match parse_datetime(s) {
1518 Ok(dt) => Some(Some(dt)),
1519 Err(e) => return Err(mcp_invalid(format!("Error parsing completion_date: {e}"))),
1520 },
1521 };
1522
1523 match manager.update_reminder(
1524 ¶ms.reminder_id,
1525 &crate::ReminderPatch {
1526 title: params.title.as_deref(),
1527 notes: params.notes.as_deref(),
1528 completed: params.completed,
1529 priority,
1530 due_date,
1531 start_date,
1532 calendar_title: params.list_name.as_deref(),
1533 due_date_timezone: tz_patch,
1534 completion_date: completion_date_patch,
1535 ..Default::default()
1536 },
1537 ) {
1538 Ok(reminder) => {
1539 let id = reminder.identifier.clone();
1540 if let Some(alarms) = ¶ms.alarms {
1541 apply_alarms_reminder(&manager, &id, alarms);
1542 }
1543 if let Some(recurrence) = ¶ms.recurrence {
1544 if recurrence.frequency.is_empty() {
1545 let _ = manager.remove_recurrence_rules(&id);
1546 } else if let Ok(rule) = parse_recurrence_param(recurrence) {
1547 let _ = manager.set_recurrence_rule(&id, &rule);
1548 }
1549 }
1550 let updated = manager.get_reminder(&id).unwrap_or(reminder);
1551 Ok(Json(ReminderOutput::from_item(&updated, &manager)))
1552 }
1553 Err(e) => Err(mcp_err(&e)),
1554 }
1555 }
1556
1557 #[tool(description = "Create a new reminder list (calendar for reminders).")]
1558 async fn create_reminder_list(
1559 &self,
1560 Parameters(params): Parameters<CreateReminderListRequest>,
1561 ) -> Result<Json<CalendarOutput>, McpError> {
1562 let manager = RemindersManager::new();
1563 match manager.create_calendar(¶ms.name) {
1564 Ok(cal) => Ok(Json(CalendarOutput::from_info(&cal))),
1565 Err(e) => Err(mcp_err(&e)),
1566 }
1567 }
1568
1569 #[tool(description = "Update a reminder list — change name and/or color.")]
1570 async fn update_reminder_list(
1571 &self,
1572 Parameters(params): Parameters<UpdateReminderListRequest>,
1573 ) -> Result<Json<CalendarOutput>, McpError> {
1574 let manager = RemindersManager::new();
1575 let color_rgba = params.color.as_ref().map(CalendarColor::to_rgba);
1576 match manager.update_calendar(¶ms.list_id, params.name.as_deref(), color_rgba) {
1577 Ok(cal) => Ok(Json(CalendarOutput::from_info(&cal))),
1578 Err(e) => Err(mcp_err(&e)),
1579 }
1580 }
1581
1582 #[tool(
1583 description = "Delete a reminder list. WARNING: This will delete all reminders in the list!"
1584 )]
1585 async fn delete_reminder_list(
1586 &self,
1587 Parameters(params): Parameters<DeleteReminderListRequest>,
1588 ) -> Result<Json<DeletedResponse>, McpError> {
1589 let manager = RemindersManager::new();
1590 match manager.delete_calendar(¶ms.list_id) {
1591 Ok(_) => Ok(Json(DeletedResponse { id: params.list_id })),
1592 Err(e) => Err(mcp_err(&e)),
1593 }
1594 }
1595
1596 #[tool(description = "Mark a reminder as completed.")]
1597 async fn complete_reminder(
1598 &self,
1599 Parameters(params): Parameters<ReminderIdRequest>,
1600 ) -> Result<Json<ReminderOutput>, McpError> {
1601 let manager = RemindersManager::new();
1602 match manager.complete_reminder(¶ms.reminder_id) {
1603 Ok(_) => {
1604 let r = manager.get_reminder(¶ms.reminder_id);
1605 match r {
1606 Ok(r) => Ok(Json(ReminderOutput::from_item(&r, &manager))),
1607 Err(e) => Err(mcp_err(&e)),
1608 }
1609 }
1610 Err(e) => Err(mcp_err(&e)),
1611 }
1612 }
1613
1614 #[tool(description = "Mark a reminder as not completed (uncomplete it).")]
1615 async fn uncomplete_reminder(
1616 &self,
1617 Parameters(params): Parameters<ReminderIdRequest>,
1618 ) -> Result<Json<ReminderOutput>, McpError> {
1619 let manager = RemindersManager::new();
1620 match manager.uncomplete_reminder(¶ms.reminder_id) {
1621 Ok(_) => {
1622 let r = manager.get_reminder(¶ms.reminder_id);
1623 match r {
1624 Ok(r) => Ok(Json(ReminderOutput::from_item(&r, &manager))),
1625 Err(e) => Err(mcp_err(&e)),
1626 }
1627 }
1628 Err(e) => Err(mcp_err(&e)),
1629 }
1630 }
1631
1632 #[tool(description = "Get a single reminder by its unique identifier.")]
1633 async fn get_reminder(
1634 &self,
1635 Parameters(params): Parameters<ReminderIdRequest>,
1636 ) -> Result<Json<ReminderOutput>, McpError> {
1637 let manager = RemindersManager::new();
1638 match manager.get_reminder(¶ms.reminder_id) {
1639 Ok(r) => Ok(Json(ReminderOutput::from_item(&r, &manager))),
1640 Err(e) => Err(mcp_err(&e)),
1641 }
1642 }
1643
1644 #[tool(description = "Delete a reminder from macOS Reminders.")]
1645 async fn delete_reminder(
1646 &self,
1647 Parameters(params): Parameters<ReminderIdRequest>,
1648 ) -> Result<Json<DeletedResponse>, McpError> {
1649 let manager = RemindersManager::new();
1650 match manager.delete_reminder(¶ms.reminder_id) {
1651 Ok(_) => Ok(Json(DeletedResponse {
1652 id: params.reminder_id,
1653 })),
1654 Err(e) => Err(mcp_err(&e)),
1655 }
1656 }
1657
1658 #[tool(
1670 description = "Set or clear the timezone applied specifically to the reminder's due date (separate from the item-level timezone). Use an IANA zone like \"America/Los_Angeles\". Pass `timezone: null` or `\"\"` to clear."
1671 )]
1672 async fn set_reminder_due_timezone(
1673 &self,
1674 Parameters(params): Parameters<SetReminderDueTimezoneRequest>,
1675 ) -> Result<Json<ReminderOutput>, McpError> {
1676 let manager = RemindersManager::new();
1677 let tz = params
1678 .timezone
1679 .as_deref()
1680 .and_then(|t| if t.is_empty() { None } else { Some(t) });
1681 manager
1682 .set_due_date_timezone(¶ms.reminder_id, tz)
1683 .map_err(|e| mcp_err(&e))?;
1684 let item = manager
1685 .get_reminder(¶ms.reminder_id)
1686 .map_err(|e| mcp_err(&e))?;
1687 Ok(Json(ReminderOutput::from_item(&item, &manager)))
1688 }
1689
1690 #[tool(
1691 description = "Attach (or clear) a geofence on a reminder. Implemented as a location-based alarm — \"remind me when I arrive at/leave this place\". Triggers a Location permission prompt the first time. Omit `geofence` to clear any existing geofence."
1692 )]
1693 async fn set_reminder_geofence(
1694 &self,
1695 Parameters(params): Parameters<SetReminderGeofenceRequest>,
1696 ) -> Result<Json<ReminderOutput>, McpError> {
1697 let manager = RemindersManager::new();
1698 let owned = params.geofence.as_ref().map(|g| {
1699 let proximity = match g.proximity {
1700 GeofenceProximity::Enter => crate::AlarmProximity::Enter,
1701 GeofenceProximity::Leave => crate::AlarmProximity::Leave,
1702 };
1703 (
1704 crate::StructuredLocation {
1705 title: g.title.clone(),
1706 latitude: g.latitude,
1707 longitude: g.longitude,
1708 radius: g.radius_meters,
1709 },
1710 proximity,
1711 )
1712 });
1713 let geofence_ref = owned.as_ref().map(|(s, p)| (s, *p));
1714 manager
1715 .set_geofence(¶ms.reminder_id, geofence_ref)
1716 .map_err(|e| mcp_err(&e))?;
1717 let item = manager
1718 .get_reminder(¶ms.reminder_id)
1719 .map_err(|e| mcp_err(&e))?;
1720 Ok(Json(ReminderOutput::from_item(&item, &manager)))
1721 }
1722
1723 #[tool(description = "List all calendars available in macOS Calendar app.")]
1728 async fn list_calendars(&self) -> Result<Json<ListResponse<CalendarOutput>>, McpError> {
1729 let manager = EventsManager::new();
1730 match manager.list_calendars() {
1731 Ok(cals) => {
1732 let items: Vec<_> = cals.iter().map(CalendarOutput::from_info).collect();
1733 Ok(Json(ListResponse {
1734 count: items.len(),
1735 items,
1736 }))
1737 }
1738 Err(e) => Err(mcp_err(&e)),
1739 }
1740 }
1741
1742 #[tool(
1743 description = "Return the calendar that will be used by `create_event` when no `calendar_name` is supplied. Mirrors `EKEventStore.defaultCalendarForNewEvents`."
1744 )]
1745 async fn get_default_event_calendar(&self) -> Result<Json<CalendarOutput>, McpError> {
1746 let manager = EventsManager::new();
1747 match manager.default_calendar() {
1748 Ok(cal) => Ok(Json(CalendarOutput::from_info(&cal))),
1749 Err(e) => Err(mcp_err(&e)),
1750 }
1751 }
1752
1753 #[tool(
1754 description = "Set an event's availability — controls how the event shows on the timeline. Use \"busy\" (default), \"free\", \"tentative\", or \"unavailable\". Always applies to just this occurrence (per-instance attribute)."
1755 )]
1756 async fn set_event_availability(
1757 &self,
1758 Parameters(params): Parameters<SetEventAvailabilityRequest>,
1759 ) -> Result<Json<EventOutput>, McpError> {
1760 let manager = EventsManager::new();
1761 let availability = parse_availability(¶ms.availability).map_err(mcp_invalid)?;
1762 manager
1763 .set_event_availability(¶ms.event_id, availability)
1764 .map_err(|e| mcp_err(&e))?;
1765 let item = manager
1766 .get_event(¶ms.event_id)
1767 .map_err(|e| mcp_err(&e))?;
1768 Ok(Json(EventOutput::from_item(&item, &manager)))
1769 }
1770
1771 #[tool(
1772 description = "List calendar events. By default shows today's events. Can specify a date range."
1773 )]
1774 async fn list_events(
1775 &self,
1776 Parameters(params): Parameters<ListEventsRequest>,
1777 ) -> Result<Json<ListResponse<EventOutput>>, McpError> {
1778 let manager = EventsManager::new();
1779
1780 let events = if params.days == 1 {
1781 manager.fetch_today_events()
1782 } else {
1783 let start = Local::now();
1784 let end = start + Duration::days(params.days);
1785 manager.fetch_events(start, end, None)
1786 };
1787
1788 match events {
1789 Ok(items) => {
1790 let filtered: Vec<_> = if let Some(ref cal_id) = params.calendar_id {
1791 items
1792 .into_iter()
1793 .filter(|e| e.calendar_id.as_deref() == Some(cal_id.as_str()))
1794 .collect()
1795 } else {
1796 items
1797 };
1798 let items: Vec<_> = filtered
1799 .iter()
1800 .map(EventOutput::from_item_summary)
1801 .collect();
1802 Ok(Json(ListResponse {
1803 count: items.len(),
1804 items,
1805 }))
1806 }
1807 Err(e) => Err(mcp_err(&e)),
1808 }
1809 }
1810
1811 #[tool(
1812 description = "Create a new calendar event in macOS Calendar. Inline configuration: title, start/end (or duration), location, calendar, all-day flag, URL, alarms (time-based; events don't support proximity alarms), recurrence."
1813 )]
1814 async fn create_event(
1815 &self,
1816 Parameters(params): Parameters<CreateEventRequest>,
1817 ) -> Result<Json<EventOutput>, McpError> {
1818 let manager = EventsManager::new();
1819
1820 let start = match parse_datetime(¶ms.start) {
1821 Ok(dt) => dt,
1822 Err(e) => return Err(mcp_invalid(format!("Error: {e}"))),
1823 };
1824
1825 let end = if let Some(end_str) = ¶ms.end {
1826 match parse_datetime(end_str) {
1827 Ok(dt) => dt,
1828 Err(e) => return Err(mcp_invalid(format!("Error: {e}"))),
1829 }
1830 } else {
1831 start + Duration::minutes(params.duration_minutes)
1832 };
1833
1834 let availability = params
1835 .availability
1836 .as_deref()
1837 .map(parse_availability)
1838 .transpose()
1839 .map_err(mcp_invalid)?;
1840
1841 let sl_owned = params
1842 .structured_location
1843 .as_ref()
1844 .map(|s| crate::StructuredLocation {
1845 title: s.title.clone(),
1846 latitude: s.latitude,
1847 longitude: s.longitude,
1848 radius: s.radius_meters,
1849 });
1850
1851 match manager.create_event(&crate::EventDraft {
1852 title: ¶ms.title,
1853 start: Some(start),
1854 end: Some(end),
1855 notes: params.notes.as_deref(),
1856 location: params.location.as_deref(),
1857 calendar_title: params.calendar_name.as_deref(),
1858 all_day: params.all_day,
1859 URL: params.URL.as_deref(),
1860 availability,
1861 structured_location: sl_owned.as_ref(),
1862 }) {
1863 Ok(event) => {
1864 let id = event.identifier.clone();
1865 if let Some(alarms) = ¶ms.alarms {
1866 apply_alarms_event(&manager, &id, alarms);
1867 }
1868 if let Some(recurrence) = ¶ms.recurrence
1869 && let Ok(rule) = parse_recurrence_param(recurrence)
1870 {
1871 let _ = manager.set_event_recurrence_rule(&id, &rule);
1872 }
1873 let updated = manager.get_event(&id).unwrap_or(event);
1874 Ok(Json(EventOutput::from_item(&updated, &manager)))
1875 }
1876 Err(e) => Err(mcp_err(&e)),
1877 }
1878 }
1879
1880 #[tool(
1881 description = "Delete a calendar event. `span: \"this\" | \"future\"` controls recurring-event scope (default: \"this\"). The legacy boolean `affect_future` is still accepted as an alias for `span: \"future\"`."
1882 )]
1883 async fn delete_event(
1884 &self,
1885 Parameters(params): Parameters<EventIdRequest>,
1886 ) -> Result<Json<DeletedResponse>, McpError> {
1887 let manager = EventsManager::new();
1888 let span = parse_span(params.span.as_deref()).map_err(mcp_invalid)?;
1889 let affect_future = matches!(span, crate::EventSpan::Future) || params.affect_future;
1890 match manager.delete_event(¶ms.event_id, affect_future) {
1891 Ok(_) => Ok(Json(DeletedResponse {
1892 id: params.event_id,
1893 })),
1894 Err(e) => Err(mcp_err(&e)),
1895 }
1896 }
1897
1898 #[tool(description = "Get a single calendar event by its unique identifier.")]
1899 async fn get_event(
1900 &self,
1901 Parameters(params): Parameters<EventIdRequest>,
1902 ) -> Result<Json<EventOutput>, McpError> {
1903 let manager = EventsManager::new();
1904 match manager.get_event(¶ms.event_id) {
1905 Ok(e) => Ok(Json(EventOutput::from_item(&e, &manager))),
1906 Err(e) => Err(mcp_err(&e)),
1907 }
1908 }
1909
1910 #[tool(description = "Create a new calendar for events.")]
1915 async fn create_event_calendar(
1916 &self,
1917 Parameters(params): Parameters<CreateReminderListRequest>,
1918 ) -> Result<Json<CalendarOutput>, McpError> {
1919 let manager = EventsManager::new();
1920 match manager.create_event_calendar(¶ms.name) {
1921 Ok(cal) => Ok(Json(CalendarOutput::from_info(&cal))),
1922 Err(e) => Err(mcp_err(&e)),
1923 }
1924 }
1925
1926 #[tool(description = "Update an event calendar — change name and/or color.")]
1927 async fn update_event_calendar(
1928 &self,
1929 Parameters(params): Parameters<UpdateEventCalendarRequest>,
1930 ) -> Result<Json<CalendarOutput>, McpError> {
1931 let manager = EventsManager::new();
1932
1933 let color_rgba = params.color.as_ref().map(CalendarColor::to_rgba);
1934
1935 match manager.update_event_calendar(¶ms.calendar_id, params.name.as_deref(), color_rgba)
1936 {
1937 Ok(cal) => Ok(Json(CalendarOutput::from_info(&cal))),
1938 Err(e) => Err(mcp_err(&e)),
1939 }
1940 }
1941
1942 #[tool(
1943 description = "Delete an event calendar. WARNING: This will delete all events in the calendar!"
1944 )]
1945 async fn delete_event_calendar(
1946 &self,
1947 Parameters(params): Parameters<DeleteReminderListRequest>,
1948 ) -> Result<Json<DeletedResponse>, McpError> {
1949 let manager = EventsManager::new();
1950 match manager.delete_event_calendar(¶ms.list_id) {
1951 Ok(()) => Ok(Json(DeletedResponse { id: params.list_id })),
1952 Err(e) => Err(mcp_err(&e)),
1953 }
1954 }
1955
1956 #[tool(description = "List all available sources (accounts like iCloud, Local, Exchange).")]
1961 async fn list_sources(&self) -> Result<Json<ListResponse<SourceOutput>>, McpError> {
1962 let manager = RemindersManager::new();
1963 match manager.list_sources() {
1964 Ok(sources) => {
1965 let items: Vec<_> = sources.iter().map(SourceOutput::from_info).collect();
1966 Ok(Json(ListResponse {
1967 count: items.len(),
1968 items,
1969 }))
1970 }
1971 Err(e) => Err(mcp_err(&e)),
1972 }
1973 }
1974
1975 #[tool(
1980 description = "Update an existing calendar event. All fields are optional; only those you supply are written. Inline edits: title, notes (empty clears), location (empty clears), start/end, all_day toggle, calendar move (`calendar_name`), URL (empty clears), availability, structured_location (null clears), alarms (replaces all), recurrence (empty frequency clears). `span: \"this\" | \"future\"` controls recurring-event edit scope; defaults to \"this\"."
1981 )]
1982 async fn update_event(
1983 &self,
1984 Parameters(params): Parameters<UpdateEventRequest>,
1985 ) -> Result<Json<EventOutput>, McpError> {
1986 let manager = EventsManager::new();
1987
1988 let start = match params.start.as_ref().map(|s| parse_datetime(s)).transpose() {
1989 Ok(v) => v,
1990 Err(e) => return Err(mcp_invalid(format!("Error: {e}"))),
1991 };
1992 let end = match params.end.as_ref().map(|s| parse_datetime(s)).transpose() {
1993 Ok(v) => v,
1994 Err(e) => return Err(mcp_invalid(format!("Error: {e}"))),
1995 };
1996
1997 fn opt_patch(s: &Option<String>) -> Option<Option<&str>> {
1999 s.as_ref()
2000 .map(|v| if v.is_empty() { None } else { Some(v.as_str()) })
2001 }
2002 let notes_patch = opt_patch(¶ms.notes);
2003 let location_patch = opt_patch(¶ms.location);
2004 let url_patch = opt_patch(¶ms.URL);
2005
2006 let availability = params
2007 .availability
2008 .as_deref()
2009 .map(parse_availability)
2010 .transpose()
2011 .map_err(mcp_invalid)?;
2012
2013 let sl_owned = params
2014 .structured_location
2015 .as_ref()
2016 .map(|s| crate::StructuredLocation {
2017 title: s.title.clone(),
2018 latitude: s.latitude,
2019 longitude: s.longitude,
2020 radius: s.radius_meters,
2021 });
2022 let sl_patch = sl_owned.as_ref().map(Some);
2027
2028 let span = parse_span(params.span.as_deref()).map_err(mcp_invalid)?;
2029
2030 match manager.update_event(
2031 ¶ms.event_id,
2032 &crate::EventPatch {
2033 title: params.title.as_deref(),
2034 notes: notes_patch,
2035 location: location_patch,
2036 start,
2037 end,
2038 all_day: params.all_day,
2039 calendar_title: params.calendar_name.as_deref(),
2040 URL: url_patch,
2041 availability,
2042 structured_location: sl_patch,
2043 span,
2044 },
2045 ) {
2046 Ok(event) => {
2047 let id = event.identifier.clone();
2048 if let Some(alarms) = ¶ms.alarms {
2049 apply_alarms_event(&manager, &id, alarms);
2050 }
2051 if let Some(recurrence) = ¶ms.recurrence {
2052 if recurrence.frequency.is_empty() {
2053 let _ = manager.remove_event_recurrence_rules(&id);
2054 } else if let Ok(rule) = parse_recurrence_param(recurrence) {
2055 let _ = manager.set_event_recurrence_rule(&id, &rule);
2056 }
2057 }
2058 let updated = manager.get_event(&id).unwrap_or(event);
2059 Ok(Json(EventOutput::from_item(&updated, &manager)))
2060 }
2061 Err(e) => Err(mcp_err(&e)),
2062 }
2063 }
2064
2065 #[cfg(feature = "location")]
2066 #[tool(
2067 description = "Get the user's current location (latitude, longitude). Requires location permission."
2068 )]
2069 async fn get_current_location(&self) -> Result<Json<CoordinateOutput>, McpError> {
2070 let manager = crate::location::LocationManager::new();
2071 match manager.get_current_location(std::time::Duration::from_secs(10)) {
2072 Ok(coord) => Ok(Json(CoordinateOutput {
2073 latitude: coord.latitude,
2074 longitude: coord.longitude,
2075 })),
2076 Err(e) => Err(McpError::internal_error(e.to_string(), None)),
2077 }
2078 }
2079 #[tool(
2084 description = "Search reminders or events by text in title or notes (case-insensitive). Specify item_type to filter, or omit to search both."
2085 )]
2086 async fn search(
2087 &self,
2088 Parameters(params): Parameters<SearchRequest>,
2089 ) -> Result<Json<SearchResponse>, McpError> {
2090 let query = params.query.to_lowercase();
2091
2092 let search_reminders = matches!(params.item_type, None | Some(ItemType::Reminder));
2093 let search_events = matches!(params.item_type, None | Some(ItemType::Event));
2094
2095 let reminders = if search_reminders {
2096 let manager = RemindersManager::new();
2097 let items = if params.include_completed {
2098 manager.fetch_all_reminders()
2099 } else {
2100 manager.fetch_incomplete_reminders()
2101 };
2102 items.ok().map(|items| {
2103 let filtered: Vec<_> = items
2104 .into_iter()
2105 .filter(|r| {
2106 r.title.to_lowercase().contains(&query)
2107 || r.notes
2108 .as_deref()
2109 .is_some_and(|n| n.to_lowercase().contains(&query))
2110 })
2111 .map(|r| ReminderOutput::from_item_summary(&r))
2112 .collect();
2113 ListResponse {
2114 count: filtered.len(),
2115 items: filtered,
2116 }
2117 })
2118 } else {
2119 None
2120 };
2121
2122 let events = if search_events {
2123 let manager = EventsManager::new();
2124 let start = Local::now();
2125 let end = start + Duration::days(params.days);
2126 manager.fetch_events(start, end, None).ok().map(|items| {
2127 let filtered: Vec<_> = items
2128 .into_iter()
2129 .filter(|e| {
2130 e.title.to_lowercase().contains(&query)
2131 || e.notes
2132 .as_deref()
2133 .is_some_and(|n| n.to_lowercase().contains(&query))
2134 })
2135 .map(|e| EventOutput::from_item_summary(&e))
2136 .collect();
2137 ListResponse {
2138 count: filtered.len(),
2139 items: filtered,
2140 }
2141 })
2142 } else {
2143 None
2144 };
2145
2146 Ok(Json(SearchResponse {
2147 query: params.query,
2148 reminders,
2149 events,
2150 }))
2151 }
2152
2153 #[tool(description = "Delete multiple reminders or events at once.")]
2158 async fn batch_delete(
2159 &self,
2160 Parameters(params): Parameters<BatchDeleteRequest>,
2161 ) -> Result<Json<BatchResponse>, McpError> {
2162 let mut succeeded = 0usize;
2163 let mut errors = Vec::new();
2164
2165 match params.item_type {
2166 ItemType::Reminder => {
2167 let manager = RemindersManager::new();
2168 for id in ¶ms.item_ids {
2169 match manager.delete_reminder(id) {
2170 Ok(_) => succeeded += 1,
2171 Err(e) => errors.push(format!("{id}: {e}")),
2172 }
2173 }
2174 }
2175 ItemType::Event => {
2176 let manager = EventsManager::new();
2177 for id in ¶ms.item_ids {
2178 match manager.delete_event(id, params.affect_future) {
2179 Ok(_) => succeeded += 1,
2180 Err(e) => errors.push(format!("{id}: {e}")),
2181 }
2182 }
2183 }
2184 }
2185
2186 let err_items: Vec<_> = errors
2187 .into_iter()
2188 .map(|e| {
2189 let (id, msg) = e.split_once(": ").unwrap_or(("unknown", &e));
2190 BatchItemError {
2191 item_id: id.to_string(),
2192 message: msg.to_string(),
2193 }
2194 })
2195 .collect();
2196 Ok(Json(BatchResponse {
2197 total: params.item_ids.len(),
2198 succeeded,
2199 failed: err_items.len(),
2200 errors: err_items,
2201 }))
2202 }
2203
2204 #[tool(description = "Move multiple reminders to a different list at once.")]
2205 async fn batch_move(
2206 &self,
2207 Parameters(params): Parameters<BatchMoveRequest>,
2208 ) -> Result<Json<BatchResponse>, McpError> {
2209 let manager = RemindersManager::new();
2210 let mut succeeded = 0usize;
2211 let mut errors = Vec::new();
2212
2213 for id in ¶ms.reminder_ids {
2214 match manager.update_reminder(
2215 id,
2216 &crate::ReminderPatch {
2217 calendar_title: Some(¶ms.destination_list_name),
2218 ..Default::default()
2219 },
2220 ) {
2221 Ok(_) => succeeded += 1,
2222 Err(e) => errors.push(format!("{id}: {e}")),
2223 }
2224 }
2225
2226 let err_items: Vec<_> = errors
2227 .into_iter()
2228 .map(|e| {
2229 let (id, msg) = e.split_once(": ").unwrap_or(("unknown", &e));
2230 BatchItemError {
2231 item_id: id.to_string(),
2232 message: msg.to_string(),
2233 }
2234 })
2235 .collect();
2236 Ok(Json(BatchResponse {
2237 total: params.reminder_ids.len(),
2238 succeeded,
2239 failed: err_items.len(),
2240 errors: err_items,
2241 }))
2242 }
2243
2244 #[tool(description = "Update multiple reminders or events at once.")]
2245 async fn batch_update(
2246 &self,
2247 Parameters(params): Parameters<BatchUpdateRequest>,
2248 ) -> Result<Json<BatchResponse>, McpError> {
2249 let mut succeeded = 0usize;
2250 let mut errors = Vec::new();
2251
2252 match params.item_type {
2253 ItemType::Reminder => {
2254 let manager = RemindersManager::new();
2255 for item in ¶ms.updates {
2256 let priority = item.priority.as_ref().map(Priority::to_usize);
2257 let due_date = match &item.due_date {
2258 Some(s) if s.is_empty() => Some(None),
2259 Some(s) => match parse_datetime_or_time(s) {
2260 Ok(dt) => Some(Some(dt)),
2261 Err(e) => {
2262 errors.push(format!("{}: {e}", item.item_id));
2263 continue;
2264 }
2265 },
2266 None => None,
2267 };
2268 match manager.update_reminder(
2269 &item.item_id,
2270 &crate::ReminderPatch {
2271 title: item.title.as_deref(),
2272 notes: item.notes.as_deref(),
2273 completed: item.completed,
2274 priority,
2275 due_date,
2276 ..Default::default()
2277 },
2278 ) {
2279 Ok(_) => succeeded += 1,
2280 Err(e) => errors.push(format!("{}: {e}", item.item_id)),
2281 }
2282 }
2283 }
2284 ItemType::Event => {
2285 let manager = EventsManager::new();
2286 for item in ¶ms.updates {
2287 match manager.update_event(
2288 &item.item_id,
2289 &crate::EventPatch {
2290 title: item.title.as_deref(),
2291 notes: item
2292 .notes
2293 .as_ref()
2294 .map(|s| if s.is_empty() { None } else { Some(s.as_str()) }),
2295 ..Default::default()
2296 },
2297 ) {
2298 Ok(_) => succeeded += 1,
2299 Err(e) => errors.push(format!("{}: {e}", item.item_id)),
2300 }
2301 }
2302 }
2303 }
2304
2305 let total = params.updates.len();
2306 let err_items: Vec<_> = errors
2307 .into_iter()
2308 .map(|e| {
2309 let (id, msg) = e.split_once(": ").unwrap_or(("unknown", &e));
2310 BatchItemError {
2311 item_id: id.to_string(),
2312 message: msg.to_string(),
2313 }
2314 })
2315 .collect();
2316 Ok(Json(BatchResponse {
2317 total,
2318 succeeded,
2319 failed: err_items.len(),
2320 errors: err_items,
2321 }))
2322 }
2323}
2324
2325fn parse_recurrence_param(
2327 params: &RecurrenceParam,
2328) -> std::result::Result<crate::RecurrenceRule, String> {
2329 let frequency = match params.frequency.as_str() {
2330 "daily" => crate::RecurrenceFrequency::Daily,
2331 "weekly" => crate::RecurrenceFrequency::Weekly,
2332 "monthly" => crate::RecurrenceFrequency::Monthly,
2333 "yearly" => crate::RecurrenceFrequency::Yearly,
2334 other => {
2335 return Err(format!(
2336 "Invalid frequency: '{}'. Use daily, weekly, monthly, or yearly.",
2337 other
2338 ));
2339 }
2340 };
2341
2342 let end = if let Some(count) = params.end_after_count {
2343 crate::RecurrenceEndCondition::AfterCount(count)
2344 } else if let Some(date_str) = ¶ms.end_date {
2345 let dt = parse_datetime(date_str)?;
2346 crate::RecurrenceEndCondition::OnDate(dt)
2347 } else {
2348 crate::RecurrenceEndCondition::Never
2349 };
2350
2351 fn check_range(
2354 name: &str,
2355 vals: &Option<Vec<i32>>,
2356 valid: impl Fn(i32) -> bool,
2357 ) -> Result<(), String> {
2358 if let Some(vs) = vals {
2359 for v in vs {
2360 if !valid(*v) {
2361 return Err(format!("{name}: value {v} out of range"));
2362 }
2363 }
2364 }
2365 Ok(())
2366 }
2367 check_range("days_of_month", ¶ms.days_of_month, |v| {
2368 (-31..=31).contains(&v) && v != 0
2369 })?;
2370 check_range("months_of_year", ¶ms.months_of_year, |v| {
2371 (1..=12).contains(&v)
2372 })?;
2373 check_range("weeks_of_year", ¶ms.weeks_of_year, |v| {
2374 (-53..=53).contains(&v) && v != 0
2375 })?;
2376 check_range("days_of_year", ¶ms.days_of_year, |v| {
2377 (-366..=366).contains(&v) && v != 0
2378 })?;
2379 check_range("set_positions", ¶ms.set_positions, |v| {
2380 (-366..=366).contains(&v) && v != 0
2381 })?;
2382
2383 Ok(crate::RecurrenceRule {
2384 frequency,
2385 interval: params.interval,
2386 end,
2387 days_of_week: params.days_of_week.clone(),
2388 days_of_month: params.days_of_month.clone(),
2389 months_of_year: params.months_of_year.clone(),
2390 weeks_of_year: params.weeks_of_year.clone(),
2391 days_of_year: params.days_of_year.clone(),
2392 set_positions: params.set_positions.clone(),
2393 })
2394}
2395
2396fn parse_datetime_or_time(s: &str) -> Result<DateTime<Local>, String> {
2398 if let Ok(dt) = parse_datetime(s) {
2400 return Ok(dt);
2401 }
2402 if let Ok(time) = chrono::NaiveTime::parse_from_str(s, "%H:%M") {
2404 let today = Local::now().date_naive();
2405 let dt = today.and_time(time);
2406 return Local
2407 .from_local_datetime(&dt)
2408 .single()
2409 .ok_or_else(|| "Invalid local datetime".to_string());
2410 }
2411 Err(
2412 "Invalid date format. Use 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM', or 'HH:MM' (uses today)"
2413 .to_string(),
2414 )
2415}
2416
2417fn apply_alarms_reminder(manager: &RemindersManager, id: &str, alarms: &[AlarmParam]) {
2419 if let Ok(existing) = manager.get_alarms(id) {
2421 for i in (0..existing.len()).rev() {
2422 let _ = manager.remove_alarm(id, i);
2423 }
2424 }
2425 for param in alarms {
2427 let alarm = alarm_param_to_info(param);
2428 let _ = manager.add_alarm(id, &alarm);
2429 }
2430}
2431
2432fn apply_alarms_event(manager: &EventsManager, id: &str, alarms: &[AlarmParam]) {
2434 if let Ok(existing) = manager.get_event_alarms(id) {
2435 for i in (0..existing.len()).rev() {
2436 let _ = manager.remove_event_alarm(id, i);
2437 }
2438 }
2439 for param in alarms {
2440 let alarm = alarm_param_to_info(param);
2441 let _ = manager.add_event_alarm(id, &alarm);
2442 }
2443}
2444
2445fn alarm_param_to_info(param: &AlarmParam) -> crate::AlarmInfo {
2447 let proximity = match param.proximity.as_deref() {
2448 Some("enter") => crate::AlarmProximity::Enter,
2449 Some("leave") => crate::AlarmProximity::Leave,
2450 _ => crate::AlarmProximity::None,
2451 };
2452 let location = if let (Some(title), Some(lat), Some(lng)) =
2453 (¶m.location_title, param.latitude, param.longitude)
2454 {
2455 Some(crate::StructuredLocation {
2456 title: title.clone(),
2457 latitude: lat,
2458 longitude: lng,
2459 radius: param.radius.unwrap_or(100.0),
2460 })
2461 } else {
2462 None
2463 };
2464 crate::AlarmInfo {
2465 relative_offset: param.relative_offset,
2466 proximity,
2467 location,
2468 email_address: param.email_address.clone(),
2469 sound_name: param.sound_name.clone(),
2470 url: param.url.clone(),
2471 ..Default::default()
2472 }
2473}
2474
2475#[prompt_router]
2480impl EventKitServer {
2481 #[prompt(
2483 name = "incomplete_reminders",
2484 description = "List all incomplete reminders"
2485 )]
2486 async fn incomplete_reminders(
2487 &self,
2488 Parameters(args): Parameters<ListRemindersPromptArgs>,
2489 ) -> Result<GetPromptResult, McpError> {
2490 let manager = RemindersManager::new();
2491 let reminders = manager.fetch_incomplete_reminders().map_err(|e| {
2492 McpError::internal_error(format!("Failed to list reminders: {e}"), None)
2493 })?;
2494
2495 let reminders: Vec<_> = if let Some(ref name) = args.list_name {
2497 reminders
2498 .into_iter()
2499 .filter(|r| r.calendar_title.as_deref() == Some(name.as_str()))
2500 .collect()
2501 } else {
2502 reminders
2503 };
2504
2505 let mut output = String::new();
2506 for r in &reminders {
2507 output.push_str(&format!(
2508 "- [{}] {} (id: {}){}{}\n",
2509 if r.completed { "x" } else { " " },
2510 r.title,
2511 r.identifier,
2512 r.due_date
2513 .map(|d| format!(", due: {}", d.format("%Y-%m-%d %H:%M")))
2514 .unwrap_or_default(),
2515 r.calendar_title
2516 .as_ref()
2517 .map(|l| format!(", list: {l}"))
2518 .unwrap_or_default(),
2519 ));
2520 }
2521
2522 if output.is_empty() {
2523 output = "No incomplete reminders found.".to_string();
2524 }
2525
2526 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
2527 PromptMessageRole::User,
2528 format!(
2529 "Here are the current incomplete reminders:\n\n{output}\n\nPlease help me manage these reminders."
2530 ),
2531 )])
2532 .with_description("Incomplete reminders"))
2533 }
2534
2535 #[prompt(
2537 name = "reminder_lists",
2538 description = "List all reminder lists available in Reminders"
2539 )]
2540 async fn reminder_lists_prompt(&self) -> Result<GetPromptResult, McpError> {
2541 let manager = RemindersManager::new();
2542 let lists = manager.list_calendars().map_err(|e| {
2543 McpError::internal_error(format!("Failed to list calendars: {e}"), None)
2544 })?;
2545
2546 let mut output = String::new();
2547 for list in &lists {
2548 output.push_str(&format!("- {} (id: {})\n", list.title, list.identifier));
2549 }
2550
2551 if output.is_empty() {
2552 output = "No reminder lists found.".to_string();
2553 }
2554
2555 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
2556 PromptMessageRole::User,
2557 format!(
2558 "Here are the available reminder lists:\n\n{output}\n\nWhich list would you like to work with?"
2559 ),
2560 )])
2561 .with_description("Available reminder lists"))
2562 }
2563
2564 #[prompt(
2566 name = "move_reminder",
2567 description = "Move a reminder to a different list"
2568 )]
2569 async fn move_reminder_prompt(
2570 &self,
2571 Parameters(args): Parameters<MoveReminderPromptArgs>,
2572 ) -> Result<GetPromptResult, McpError> {
2573 let manager = RemindersManager::new();
2574
2575 let lists = manager.list_calendars().map_err(|e| {
2577 McpError::internal_error(format!("Failed to list calendars: {e}"), None)
2578 })?;
2579
2580 let dest = lists.iter().find(|l| {
2581 l.title
2582 .to_lowercase()
2583 .contains(&args.destination_list.to_lowercase())
2584 });
2585
2586 match dest {
2587 Some(dest_list) => {
2588 match manager.update_reminder(
2589 &args.reminder_id,
2590 &crate::ReminderPatch {
2591 calendar_title: Some(&dest_list.title),
2592 ..Default::default()
2593 },
2594 ) {
2595 Ok(updated) => Ok(GetPromptResult::new(vec![PromptMessage::new_text(
2596 PromptMessageRole::User,
2597 format!(
2598 "Moved reminder \"{}\" to list \"{}\".",
2599 updated.title, dest_list.title
2600 ),
2601 )])
2602 .with_description("Reminder moved")),
2603 Err(e) => Ok(GetPromptResult::new(vec![PromptMessage::new_text(
2604 PromptMessageRole::User,
2605 format!("Failed to move reminder: {e}"),
2606 )])
2607 .with_description("Move failed")),
2608 }
2609 }
2610 None => {
2611 let available: Vec<&str> = lists.iter().map(|l| l.title.as_str()).collect();
2612 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
2613 PromptMessageRole::User,
2614 format!(
2615 "Could not find reminder list \"{}\". Available lists: {}",
2616 args.destination_list,
2617 available.join(", ")
2618 ),
2619 )])
2620 .with_description("List not found"))
2621 }
2622 }
2623 }
2624
2625 #[prompt(
2627 name = "create_detailed_reminder",
2628 description = "Create a reminder with detailed context like notes, priority, and due date"
2629 )]
2630 async fn create_detailed_reminder_prompt(
2631 &self,
2632 Parameters(args): Parameters<CreateReminderPromptArgs>,
2633 ) -> Result<GetPromptResult, McpError> {
2634 let manager = RemindersManager::new();
2635
2636 let due = args
2637 .due_date
2638 .as_deref()
2639 .map(parse_datetime)
2640 .transpose()
2641 .map_err(|e| McpError::internal_error(format!("Invalid due date: {e}"), None))?;
2642
2643 match manager.create_reminder(&crate::ReminderDraft {
2644 title: &args.title,
2645 notes: args.notes.as_deref(),
2646 calendar_title: args.list_name.as_deref(),
2647 priority: args.priority.map(|p| p as usize),
2648 due_date: due,
2649 ..Default::default()
2650 }) {
2651 Ok(reminder) => {
2652 let mut details = format!("Created reminder: \"{}\"", reminder.title);
2653 if let Some(notes) = &reminder.notes {
2654 details.push_str(&format!("\nNotes: {notes}"));
2655 }
2656 if reminder.priority > 0 {
2657 details.push_str(&format!("\nPriority: {}", reminder.priority));
2658 }
2659 if let Some(due) = &reminder.due_date {
2660 details.push_str(&format!("\nDue: {}", due.format("%Y-%m-%d %H:%M")));
2661 }
2662 if let Some(list) = &reminder.calendar_title {
2663 details.push_str(&format!("\nList: {list}"));
2664 }
2665
2666 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
2667 PromptMessageRole::User,
2668 details,
2669 )])
2670 .with_description("Reminder created"))
2671 }
2672 Err(e) => Ok(GetPromptResult::new(vec![PromptMessage::new_text(
2673 PromptMessageRole::User,
2674 format!("Failed to create reminder: {e}"),
2675 )])
2676 .with_description("Creation failed")),
2677 }
2678 }
2679}
2680
2681#[tool_handler]
2683#[prompt_handler]
2684impl rmcp::ServerHandler for EventKitServer {
2685 fn get_info(&self) -> ServerInfo {
2686 ServerInfo::new(
2687 ServerCapabilities::builder()
2688 .enable_tools()
2689 .enable_prompts()
2690 .build(),
2691 )
2692 .with_instructions(
2693 "This MCP server provides access to macOS Calendar events and Reminders. \
2694 Use the available tools to list, create, update, and delete calendar events \
2695 and reminders. Authorization is handled automatically on first use.",
2696 )
2697 }
2698}
2699
2700pub async fn serve_on<T>(transport: T) -> anyhow::Result<()>
2705where
2706 T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
2707{
2708 let server = EventKitServer::new();
2709 let service = server.serve(transport).await?;
2710 service.waiting().await?;
2711 Ok(())
2712}
2713
2714pub async fn run_mcp_server() -> anyhow::Result<()> {
2719 tracing_subscriber::fmt()
2720 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
2721 .with_writer(std::io::stderr)
2722 .init();
2723
2724 let server = EventKitServer::new();
2725 let service = server.serve(stdio()).await?;
2726 service.waiting().await?;
2727 Ok(())
2728}
2729
2730pub fn dump_reminder(id: &str) -> Result<String, crate::EventKitError> {
2736 let manager = RemindersManager::new();
2737 let r = manager.get_reminder(id)?;
2738 let output = ReminderOutput::from_item(&r, &manager);
2739 Ok(serde_json::to_string_pretty(&output).unwrap())
2740}
2741
2742pub fn dump_reminder_raw(id: &str, read_values: bool) -> Result<String, crate::EventKitError> {
2750 let manager = RemindersManager::new();
2751 manager.dump_reminder_raw(id, read_values)
2752}
2753
2754pub fn dump_reminder_private(id: &str) -> Result<String, crate::EventKitError> {
2757 let manager = RemindersManager::new();
2758 manager.dump_reminder_private(id)
2759}
2760
2761pub fn dump_reminders(list_name: Option<&str>) -> Result<String, crate::EventKitError> {
2763 let manager = RemindersManager::new();
2764 let items = manager.fetch_all_reminders()?;
2765 let filtered: Vec<_> = if let Some(name) = list_name {
2766 items
2767 .into_iter()
2768 .filter(|r| r.calendar_title.as_deref() == Some(name))
2769 .collect()
2770 } else {
2771 items
2772 };
2773 let output: Vec<_> = filtered
2774 .iter()
2775 .map(ReminderOutput::from_item_summary)
2776 .collect();
2777 Ok(serde_json::to_string_pretty(&output).unwrap())
2778}
2779
2780pub fn dump_event(id: &str) -> Result<String, crate::EventKitError> {
2782 let manager = EventsManager::new();
2783 let e = manager.get_event(id)?;
2784 let output = EventOutput::from_item(&e, &manager);
2785 Ok(serde_json::to_string_pretty(&output).unwrap())
2786}
2787
2788pub fn dump_events(days: i64) -> Result<String, crate::EventKitError> {
2790 let manager = EventsManager::new();
2791 let items = manager.fetch_upcoming_events(days)?;
2792 let output: Vec<_> = items.iter().map(EventOutput::from_item_summary).collect();
2793 Ok(serde_json::to_string_pretty(&output).unwrap())
2794}
2795
2796pub fn dump_reminder_lists() -> Result<String, crate::EventKitError> {
2798 let manager = RemindersManager::new();
2799 let lists = manager.list_calendars()?;
2800 let output: Vec<_> = lists.iter().map(CalendarOutput::from_info).collect();
2801 Ok(serde_json::to_string_pretty(&output).unwrap())
2802}
2803
2804pub fn dump_calendars() -> Result<String, crate::EventKitError> {
2806 let manager = EventsManager::new();
2807 let cals = manager.list_calendars()?;
2808 let output: Vec<_> = cals.iter().map(CalendarOutput::from_info).collect();
2809 Ok(serde_json::to_string_pretty(&output).unwrap())
2810}
2811
2812pub fn dump_sources() -> Result<String, crate::EventKitError> {
2814 let manager = RemindersManager::new();
2815 let sources = manager.list_sources()?;
2816 let output: Vec<_> = sources.iter().map(SourceOutput::from_info).collect();
2817 Ok(serde_json::to_string_pretty(&output).unwrap())
2818}
2819
2820#[cfg(test)]
2821mod tests {
2822 use super::*;
2823 use crate::EventKitError;
2824
2825 #[test]
2826 fn parse_availability_accepts_every_variant() {
2827 for (s, expected) in [
2828 ("busy", crate::EventAvailability::Busy),
2829 ("free", crate::EventAvailability::Free),
2830 ("tentative", crate::EventAvailability::Tentative),
2831 ("unavailable", crate::EventAvailability::Unavailable),
2832 ("not_supported", crate::EventAvailability::NotSupported),
2833 ] {
2834 assert_eq!(parse_availability(s).unwrap(), expected);
2835 }
2836 assert!(parse_availability("BUSY").is_err()); assert!(parse_availability("anything else").is_err());
2838 }
2839
2840 #[test]
2841 fn parse_span_defaults_to_this() {
2842 assert_eq!(parse_span(None).unwrap(), crate::EventSpan::This);
2843 assert_eq!(parse_span(Some("this")).unwrap(), crate::EventSpan::This);
2844 assert_eq!(
2845 parse_span(Some("future")).unwrap(),
2846 crate::EventSpan::Future
2847 );
2848 assert!(parse_span(Some("bogus")).is_err());
2849 }
2850
2851 #[test]
2852 fn create_event_request_deserializes_new_fields() {
2853 let json = serde_json::json!({
2855 "title": "Plan",
2856 "start": "2026-06-01 14:00",
2857 "all_day": false,
2858 "availability": "tentative",
2859 "structured_location": {
2860 "title": "Office",
2861 "latitude": 37.78,
2862 "longitude": -122.42,
2863 "radius_meters": 100.0
2864 }
2865 });
2866 let req: CreateEventRequest = serde_json::from_value(json).unwrap();
2867 assert_eq!(req.availability.as_deref(), Some("tentative"));
2868 assert_eq!(req.structured_location.unwrap().title, "Office");
2869 }
2870
2871 #[test]
2872 fn update_event_request_deserializes_span_and_new_fields() {
2873 let json = serde_json::json!({
2874 "event_id": "ABC",
2875 "all_day": true,
2876 "calendar_name": "Work",
2877 "availability": "free",
2878 "span": "future",
2879 });
2880 let req: UpdateEventRequest = serde_json::from_value(json).unwrap();
2881 assert_eq!(req.all_day, Some(true));
2882 assert_eq!(req.calendar_name.as_deref(), Some("Work"));
2883 assert_eq!(req.availability.as_deref(), Some("free"));
2884 assert_eq!(req.span.as_deref(), Some("future"));
2885 }
2886
2887 #[test]
2888 fn auth_status_str_covers_every_variant() {
2889 for s in [
2893 AuthorizationStatus::NotDetermined,
2894 AuthorizationStatus::Restricted,
2895 AuthorizationStatus::Denied,
2896 AuthorizationStatus::FullAccess,
2897 AuthorizationStatus::WriteOnly,
2898 ] {
2899 let str_form = auth_status_str(s);
2900 assert!(!str_form.is_empty(), "empty string for {s:?}");
2901 assert!(
2902 !str_form.contains(' '),
2903 "MCP wire format should be PascalCase, got {str_form:?} for {s:?}"
2904 );
2905 }
2906 }
2907
2908 #[test]
2909 fn auth_remediation_absent_when_both_granted() {
2910 for r in [
2911 AuthorizationStatus::FullAccess,
2912 AuthorizationStatus::WriteOnly,
2913 ] {
2914 for e in [
2915 AuthorizationStatus::FullAccess,
2916 AuthorizationStatus::WriteOnly,
2917 ] {
2918 assert!(
2919 auth_remediation(r, e).is_none(),
2920 "expected no remediation for ({r:?}, {e:?})"
2921 );
2922 }
2923 }
2924 }
2925
2926 #[test]
2927 fn auth_remediation_picks_worst_status() {
2928 let hint = auth_remediation(
2932 AuthorizationStatus::NotDetermined,
2933 AuthorizationStatus::Denied,
2934 )
2935 .expect("expected remediation");
2936 assert!(
2937 hint.contains("System Settings") || hint.contains("tccutil"),
2938 "Denied remediation should mention System Settings or tccutil; got: {hint}"
2939 );
2940 }
2941
2942 #[test]
2943 fn auth_remediation_notdetermined_mentions_consent_dialog() {
2944 let hint = auth_remediation(
2945 AuthorizationStatus::NotDetermined,
2946 AuthorizationStatus::NotDetermined,
2947 )
2948 .expect("expected remediation");
2949 assert!(
2950 hint.contains("consent dialog"),
2951 "NotDetermined remediation should mention the consent dialog; got: {hint}"
2952 );
2953 }
2954
2955 #[test]
2956 fn mcp_err_includes_remediation_hint_for_auth_variants() {
2957 let err = mcp_err(&EventKitError::AuthorizationDenied);
2961 assert!(
2962 err.message.contains("System Settings") && err.message.contains("auth_status"),
2963 "AuthorizationDenied should mention System Settings and the auth_status tool; got: {}",
2964 err.message
2965 );
2966
2967 let err = mcp_err(&EventKitError::AuthorizationNotDetermined);
2968 assert!(
2969 err.message.contains("Info.plist"),
2970 "AuthorizationNotDetermined should hint at Info.plist usage strings; got: {}",
2971 err.message
2972 );
2973
2974 let err = mcp_err(&EventKitError::AuthorizationRestricted);
2975 assert!(
2976 err.message.contains("MDM") || err.message.contains("policy"),
2977 "AuthorizationRestricted should mention policy/MDM; got: {}",
2978 err.message
2979 );
2980 }
2981
2982 #[test]
2983 fn mcp_err_passes_through_non_auth_errors() {
2984 let err = mcp_err(&EventKitError::ItemNotFound("xyz".into()));
2985 assert!(
2986 err.message.contains("xyz"),
2987 "non-auth errors should preserve the original message; got: {}",
2988 err.message
2989 );
2990 }
2991}