Skip to main content

eventkit/
mcp.rs

1//! EventKit MCP Server
2//!
3//! A Model Context Protocol (MCP) server that exposes macOS Calendar and Reminders
4//! functionality via the EventKit framework.
5//!
6//! This module is gated behind the `mcp` feature flag.
7
8use 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
20// ============================================================================
21// Structured Output Types
22// ============================================================================
23
24/// Convert an EventKitError into an McpError for tool returns.
25///
26/// Authorization variants get expanded with a remediation hint so the agent
27/// can guide the user to fix the underlying TCC state.
28fn 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    /// One of: "FullAccess", "WriteOnly", "Denied", "NotDetermined", "Restricted"
108    reminders: &'static str,
109    /// One of: "FullAccess", "WriteOnly", "Denied", "NotDetermined", "Restricted"
110    events: &'static str,
111    /// Human-readable next step. Absent when both statuses are FullAccess/WriteOnly.
112    #[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    /// Which EventKit entity to request access for.
126    pub entity: AccessEntity,
127}
128
129#[derive(Serialize, JsonSchema)]
130struct RequestAccessOutput {
131    granted: bool,
132    /// Status after the request. One of: "FullAccess", "WriteOnly",
133    /// "Denied", "NotDetermined", "Restricted".
134    status: &'static str,
135}
136
137#[derive(Debug, Serialize, Deserialize, JsonSchema)]
138pub struct SetReminderDueTimezoneRequest {
139    pub reminder_id: String,
140    /// IANA zone name, e.g. `"America/Los_Angeles"`. Pass `null`/`""` to clear.
141    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    /// Radius in meters.
157    pub radius_meters: f64,
158    /// Trigger when entering vs leaving the radius.
159    pub proximity: GeofenceProximity,
160}
161
162#[derive(Debug, Serialize, Deserialize, JsonSchema)]
163pub struct SetReminderGeofenceRequest {
164    pub reminder_id: String,
165    /// Pass the geofence to attach, or omit/null to clear any existing
166    /// location-based alarm on the reminder.
167    pub geofence: Option<GeofenceInput>,
168}
169
170/// Structured location metadata for an event — title + lat/lng + a display
171/// radius. Unlike `GeofenceInput`, there's no proximity trigger; events use
172/// this for travel-time, map preview, and Siri suggestions.
173#[derive(Debug, Serialize, Deserialize, JsonSchema)]
174pub struct StructuredLocationInput {
175    pub title: String,
176    pub latitude: f64,
177    pub longitude: f64,
178    /// Display radius in meters (0 = labeled point with no radius).
179    pub radius_meters: f64,
180}
181
182#[derive(Debug, Serialize, Deserialize, JsonSchema)]
183pub struct SetEventAvailabilityRequest {
184    pub event_id: String,
185    /// "busy" | "free" | "tentative" | "unavailable".
186    pub availability: String,
187}
188
189/// Parse an availability string into our `EventAvailability` enum.
190/// Accepts: "busy", "free", "tentative", "unavailable", "not_supported".
191fn 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
204/// Parse a span string ("this" | "future") into our `EventSpan` enum.
205/// Defaults to `This` if omitted.
206fn 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    /// Derived from which optional fields are set: "display" | "audio" |
295    /// "procedure" | "email" | "unknown".
296    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    /// IANA zone applied to the due date specifically (separate from the
438    /// item-level timezone). Lets a reminder fire at the same wall-clock
439    /// time regardless of the device's current zone.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    due_date_timezone: Option<String>,
442    /// Geofence attached via a location-based alarm.
443    #[serde(skip_serializing_if = "Option::is_none")]
444    geofence: Option<LocationOutput>,
445    /// Parent reminder identifier when this is a subtask.
446    #[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    /// Item-level timezone hint (`EKCalendarItem.timeZone`), distinct from
558    /// the timezone applied to `start` / `end`.
559    #[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    /// Which `availability` values this calendar accepts on its events:
648    /// subset of `["busy", "free", "tentative", "unavailable"]`. Empty
649    /// when the calendar holds reminders only, or for source backends that
650    /// report `EKCalendarEventAvailabilityNone`.
651    #[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// ============================================================================
693// Shared Enums
694// ============================================================================
695
696/// Priority level for reminders.
697#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
698#[serde(rename_all = "lowercase")]
699pub enum Priority {
700    /// No priority (0)
701    None,
702    /// Low priority (9)
703    Low,
704    /// Medium priority (5)
705    Medium,
706    /// High priority (1) — also shows as "flagged" in Reminders.app
707    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/// Item type discriminator for unified tools.
731#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
732#[serde(rename_all = "lowercase")]
733pub enum ItemType {
734    Reminder,
735    Event,
736}
737
738// ============================================================================
739// Inline Alarm & Recurrence Param Types
740// ============================================================================
741
742/// Alarm configuration for inline use in create/update.
743#[derive(Debug, Serialize, Deserialize, JsonSchema)]
744pub struct AlarmParam {
745    /// Offset in seconds before the due date (negative = before, e.g., -600 = 10 minutes before)
746    pub relative_offset: Option<f64>,
747    /// Proximity trigger: "enter" or "leave" (for location-based alarms on reminders)
748    pub proximity: Option<String>,
749    /// Title of the location for geofenced alarms
750    pub location_title: Option<String>,
751    /// Latitude of the location
752    pub latitude: Option<f64>,
753    /// Longitude of the location
754    pub longitude: Option<f64>,
755    /// Geofence radius in meters (default: 100)
756    pub radius: Option<f64>,
757    /// Email address — if set, EventKit treats this as an email-type alarm
758    /// (server-side notification for CalDAV calendars).
759    pub email_address: Option<String>,
760    /// Custom audio cue name — if set, EventKit treats this as an audio-type
761    /// alarm. Macos sound names: "Glass", "Ping", "Pop", etc.
762    pub sound_name: Option<String>,
763    /// URL opened when the alarm fires — if set, EventKit treats this as a
764    /// procedure-type alarm. Apple deprecated this property in macOS 10.9
765    /// but it still functions. Strictly RFC 3986 validated.
766    pub url: Option<String>,
767}
768
769/// Recurrence configuration for inline use in create/update.
770#[derive(Debug, Serialize, Deserialize, JsonSchema)]
771pub struct RecurrenceParam {
772    /// Frequency: "daily", "weekly", "monthly", or "yearly"
773    pub frequency: String,
774    /// Repeat every N intervals (e.g., 2 = every 2 weeks). Default: 1
775    #[serde(default = "default_interval")]
776    #[schemars(with = "i64")]
777    pub interval: usize,
778    /// Days of the week (1=Sun, 2=Mon, ..., 7=Sat) for weekly/monthly rules
779    #[schemars(with = "Option<Vec<i32>>")]
780    pub days_of_week: Option<Vec<u8>>,
781    /// Days of the month (1..=31, or negatives counting from the end) for monthly rules
782    pub days_of_month: Option<Vec<i32>>,
783    /// Months of the year (1..=12) for yearly rules. e.g. `[3]` = every March
784    pub months_of_year: Option<Vec<i32>>,
785    /// Weeks of the year (1..=53, or negatives counting from the end) for yearly rules
786    pub weeks_of_year: Option<Vec<i32>>,
787    /// Days of the year (1..=366, or negatives counting from the end) for yearly rules
788    pub days_of_year: Option<Vec<i32>>,
789    /// Set positions — filter applied after other fields. e.g. with
790    /// `frequency=monthly, days_of_week=[2], set_positions=[1]` = "first Monday
791    /// of every month". Negative values count from the end.
792    pub set_positions: Option<Vec<i32>>,
793    /// End after this many occurrences (mutually exclusive with end_date)
794    #[schemars(with = "Option<i64>")]
795    pub end_after_count: Option<usize>,
796    /// End on this date in format 'YYYY-MM-DD' (mutually exclusive with end_after_count)
797    pub end_date: Option<String>,
798}
799
800// ============================================================================
801// Request/Response Types for Tools
802// ============================================================================
803
804#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
805pub struct ListRemindersRequest {
806    /// If true, show all reminders including completed ones. Default: false.
807    /// Ignored when any `completed_*` filter is supplied (those imply
808    /// completed-only).
809    #[serde(default)]
810    pub show_completed: bool,
811    /// Optional: Filter to a specific reminder list by name
812    pub list_name: Option<String>,
813    /// Only return *incomplete* reminders whose due date is at or after this
814    /// timestamp. Format: 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM'.
815    pub due_after: Option<String>,
816    /// Only return *incomplete* reminders whose due date is before this
817    /// timestamp. Pair with `due_after` for a window.
818    pub due_before: Option<String>,
819    /// Only return *completed* reminders whose completion date is at or after
820    /// this timestamp. Presence of this field implies completed-only mode.
821    pub completed_after: Option<String>,
822    /// Only return *completed* reminders whose completion date is before
823    /// this timestamp. Presence of this field implies completed-only mode.
824    pub completed_before: Option<String>,
825}
826
827#[derive(Debug, Serialize, Deserialize, JsonSchema)]
828#[allow(non_snake_case)]
829pub struct CreateReminderRequest {
830    /// The title/name of the reminder
831    pub title: String,
832    /// The name of the reminder list to add to (REQUIRED - use list_reminder_lists to see available lists)
833    pub list_name: String,
834    /// Optional notes/description for the reminder
835    pub notes: Option<String>,
836    /// Priority: "none", "low", "medium", "high" (high = flagged)
837    pub priority: Option<Priority>,
838    /// Optional due date in format 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM'. If only time 'HH:MM' is given, today's date is used.
839    pub due_date: Option<String>,
840    /// Optional start date when to begin working (format: 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM')
841    pub start_date: Option<String>,
842    /// Optional IANA timezone applied specifically to the due date
843    /// (e.g. "America/Los_Angeles"). Lets the reminder fire at the same
844    /// wall-clock time regardless of the device's current zone.
845    pub due_date_timezone: Option<String>,
846    /// Optional geofence — attaches a location-based alarm with the given
847    /// title/lat/lng/radius/proximity. Triggers a Location permission
848    /// prompt the first time it's used. This is the only location path
849    /// iCloud Reminders honors; the plain `location` and rich-link
850    /// `structuredLocation` properties on `EKReminder` are silently
851    /// dropped by the iCloud daemon and so aren't exposed here.
852    pub geofence: Option<GeofenceInput>,
853    /// Optional alarms (replaces all existing). Each alarm can be time-based or location-based.
854    pub alarms: Option<Vec<AlarmParam>>,
855    /// Optional recurrence rule (replaces existing)
856    pub recurrence: Option<RecurrenceParam>,
857}
858
859#[derive(Debug, Serialize, Deserialize, JsonSchema)]
860#[allow(non_snake_case)]
861pub struct UpdateReminderRequest {
862    /// The unique identifier of the reminder to update
863    pub reminder_id: String,
864    /// The name of the reminder list to move this reminder to
865    pub list_name: Option<String>,
866    /// New title for the reminder
867    pub title: Option<String>,
868    /// New notes for the reminder
869    pub notes: Option<String>,
870    /// Mark as completed (true) or incomplete (false)
871    pub completed: Option<bool>,
872    /// Priority: "none", "low", "medium", "high" (high = flagged)
873    pub priority: Option<Priority>,
874    /// New due date in format 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM'. Set to empty string to clear.
875    pub due_date: Option<String>,
876    /// New start date. Set to empty string to clear.
877    pub start_date: Option<String>,
878    /// IANA timezone applied specifically to the due date
879    /// (e.g. "America/Los_Angeles"). Set to empty string to clear.
880    pub due_date_timezone: Option<String>,
881    /// Explicit completion timestamp. Setting this implicitly marks the
882    /// reminder completed; setting it to `""` clears completion (and the
883    /// reminder becomes incomplete). Apple's `setCompletionDate:` is
884    /// the authoritative completion toggle, so this wins over `completed`
885    /// when both are provided. ISO format: 'YYYY-MM-DD' or
886    /// 'YYYY-MM-DDTHH:MM:SS±HH:MM'.
887    pub completion_date: Option<String>,
888    /// Alarms (replaces all existing when provided). Pass empty array to clear.
889    pub alarms: Option<Vec<AlarmParam>>,
890    /// Recurrence rule (replaces existing when provided). Omit to keep, set frequency to "" to clear.
891    pub recurrence: Option<RecurrenceParam>,
892}
893
894#[derive(Debug, Serialize, Deserialize, JsonSchema)]
895pub struct CreateReminderListRequest {
896    /// The name of the new reminder list to create
897    pub name: String,
898}
899
900/// Predefined colors for calendars and reminder lists.
901#[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    /// Find the closest named color for an RGBA value.
931    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    /// The unique identifier of the reminder list to update
958    pub list_id: String,
959    /// New name for the list (optional)
960    pub name: Option<String>,
961    /// Color for the list (optional). Use a color name or custom hex.
962    pub color: Option<CalendarColor>,
963}
964
965#[derive(Debug, Serialize, Deserialize, JsonSchema)]
966pub struct UpdateEventCalendarRequest {
967    /// The unique identifier of the event calendar to update
968    pub calendar_id: String,
969    /// New name for the calendar (optional)
970    pub name: Option<String>,
971    /// Color for the calendar (optional). Use a color name or custom hex.
972    pub color: Option<CalendarColor>,
973}
974
975#[derive(Debug, Serialize, Deserialize, JsonSchema)]
976pub struct DeleteReminderListRequest {
977    /// The unique identifier of the reminder list to delete
978    pub list_id: String,
979}
980
981#[derive(Debug, Serialize, Deserialize, JsonSchema)]
982pub struct ReminderIdRequest {
983    /// The unique identifier of the reminder
984    pub reminder_id: String,
985}
986
987#[derive(Debug, Serialize, Deserialize, JsonSchema)]
988pub struct ListEventsRequest {
989    /// Number of days from today to include (default: 1 for today only)
990    #[serde(default = "default_days")]
991    pub days: i64,
992    /// Optional: Filter to a specific calendar by ID (use list_calendars to get IDs)
993    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    /// The title of the event
1004    pub title: String,
1005    /// Start date/time in format 'YYYY-MM-DD HH:MM' or 'YYYY-MM-DD' for all-day events
1006    pub start: String,
1007    /// End date/time in format 'YYYY-MM-DD HH:MM'. If not specified, uses duration_minutes.
1008    pub end: Option<String>,
1009    /// Duration in minutes (default: 60). Used if end is not specified.
1010    #[serde(default = "default_duration")]
1011    pub duration_minutes: i64,
1012    /// Optional notes/description for the event
1013    pub notes: Option<String>,
1014    /// Optional location for the event
1015    pub location: Option<String>,
1016    /// Optional: The name of the calendar to add to
1017    pub calendar_name: Option<String>,
1018    /// Whether this is an all-day event
1019    #[serde(default)]
1020    pub all_day: bool,
1021    /// Optional URL to associate with the event
1022    pub URL: Option<String>,
1023    /// Optional availability: "busy" (default), "free", "tentative",
1024    /// "unavailable". Controls how the event shows on the timeline.
1025    pub availability: Option<String>,
1026    /// Optional structured location (title + lat/lng + radius). Enables
1027    /// travel-time, map preview, and "leave at" suggestions in Calendar.app.
1028    pub structured_location: Option<StructuredLocationInput>,
1029    /// Optional alarms (replaces all existing). Time-based only for events.
1030    pub alarms: Option<Vec<AlarmParam>>,
1031    /// Optional recurrence rule
1032    pub recurrence: Option<RecurrenceParam>,
1033}
1034
1035fn default_duration() -> i64 {
1036    60
1037}
1038
1039#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1040pub struct EventIdRequest {
1041    /// The unique identifier of the event
1042    pub event_id: String,
1043    /// Edit scope for recurring events: "this" (default) or "future".
1044    /// Mirrors `EKSpan::ThisEvent` / `EKSpan::FutureEvents`.
1045    pub span: Option<String>,
1046    /// Deprecated alias for `span`. If true, equivalent to `span: "future"`.
1047    /// New callers should use `span`; kept for back-compat.
1048    #[serde(default)]
1049    pub affect_future: bool,
1050}
1051
1052#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1053#[allow(non_snake_case)]
1054pub struct UpdateEventRequest {
1055    /// The unique identifier of the event to update
1056    pub event_id: String,
1057    /// New title for the event
1058    pub title: Option<String>,
1059    /// New notes for the event (empty string clears)
1060    pub notes: Option<String>,
1061    /// New location for the event (empty string clears)
1062    pub location: Option<String>,
1063    /// New start date/time in format 'YYYY-MM-DD HH:MM'
1064    pub start: Option<String>,
1065    /// New end date/time in format 'YYYY-MM-DD HH:MM'
1066    pub end: Option<String>,
1067    /// Toggle all-day flag.
1068    pub all_day: Option<bool>,
1069    /// Move to another calendar by name.
1070    pub calendar_name: Option<String>,
1071    /// URL to associate (empty string clears)
1072    pub URL: Option<String>,
1073    /// New availability: "busy" | "free" | "tentative" | "unavailable".
1074    pub availability: Option<String>,
1075    /// New structured location; pass `null` to clear.
1076    pub structured_location: Option<StructuredLocationInput>,
1077    /// Edit scope for recurring events: "this" (default — only this
1078    /// occurrence) or "future" (this and every later occurrence).
1079    pub span: Option<String>,
1080    /// Alarms (replaces all existing when provided). Pass empty array to clear.
1081    pub alarms: Option<Vec<AlarmParam>>,
1082    /// Recurrence rule (replaces existing when provided)
1083    pub recurrence: Option<RecurrenceParam>,
1084}
1085
1086// ============================================================================
1087// Batch Operation Request Types
1088// ============================================================================
1089
1090#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1091pub struct BatchDeleteRequest {
1092    /// Whether these are "reminder" or "event" items
1093    pub item_type: ItemType,
1094    /// List of item IDs to delete
1095    pub item_ids: Vec<String>,
1096    /// For recurring events: if true, delete this and all future occurrences
1097    #[serde(default)]
1098    pub affect_future: bool,
1099}
1100
1101#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1102pub struct BatchMoveRequest {
1103    /// List of reminder IDs to move
1104    pub reminder_ids: Vec<String>,
1105    /// The name of the destination reminder list
1106    pub destination_list_name: String,
1107}
1108
1109#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1110pub struct BatchUpdateItem {
1111    /// The unique identifier of the item to update
1112    pub item_id: String,
1113    /// New title
1114    pub title: Option<String>,
1115    /// New notes
1116    pub notes: Option<String>,
1117    /// Mark completed (reminders only)
1118    pub completed: Option<bool>,
1119    /// Priority (reminders only)
1120    pub priority: Option<Priority>,
1121    /// Due date (reminders only)
1122    pub due_date: Option<String>,
1123}
1124
1125#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1126pub struct BatchUpdateRequest {
1127    /// Whether these are "reminder" or "event" items
1128    pub item_type: ItemType,
1129    /// List of updates to apply
1130    pub updates: Vec<BatchUpdateItem>,
1131}
1132
1133#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1134pub struct SearchRequest {
1135    /// Text to search for in titles and notes (case-insensitive)
1136    pub query: String,
1137    /// Whether to search "reminder" or "event" items. If omitted, searches both.
1138    pub item_type: Option<ItemType>,
1139    /// For reminders: if true, also search completed reminders. Default: false
1140    #[serde(default)]
1141    pub include_completed: bool,
1142    /// For events: number of days from today to search (default: 30)
1143    #[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// ============================================================================
1156// Prompt Argument Types
1157// ============================================================================
1158
1159#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1160pub struct ListRemindersPromptArgs {
1161    /// Name of the reminder list to show. If not provided, shows all lists.
1162    #[serde(default)]
1163    pub list_name: Option<String>,
1164}
1165
1166#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1167pub struct MoveReminderPromptArgs {
1168    /// The unique identifier of the reminder to move
1169    pub reminder_id: String,
1170    /// The name of the destination reminder list
1171    pub destination_list: String,
1172}
1173
1174#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1175pub struct CreateReminderPromptArgs {
1176    /// Title of the reminder
1177    pub title: String,
1178    /// Detailed notes/context for the reminder
1179    #[serde(default)]
1180    pub notes: Option<String>,
1181    /// Name of the reminder list to add to
1182    #[serde(default)]
1183    pub list_name: Option<String>,
1184    /// Priority (0 = none, 1-4 = high, 5 = medium, 6-9 = low)
1185    #[serde(default)]
1186    #[schemars(with = "Option<i32>")]
1187    pub priority: Option<u8>,
1188    /// Due date in format "YYYY-MM-DD" or "YYYY-MM-DD HH:MM"
1189    #[serde(default)]
1190    pub due_date: Option<String>,
1191}
1192
1193// ============================================================================
1194// EventKit MCP Server
1195// ============================================================================
1196
1197/// EventKit MCP Server - provides access to macOS Calendar and Reminders.
1198///
1199/// EventKit objects (`Retained<EKEventStore>` and its managers) are `!Send + !Sync`,
1200/// but every handler in this module keeps those values stack-local and never holds
1201/// one across an `.await`. That makes the generated handler futures `Send`, so the
1202/// server can run on a normal multi-thread tokio runtime without rmcp's `local`
1203/// feature. New handlers MUST preserve this invariant — if you need async work,
1204/// wrap the synchronous EventKit calls in `tokio::task::spawn_blocking` so the
1205/// `!Send` value lives entirely inside the blocking closure.
1206pub struct EventKitServer {}
1207
1208impl Default for EventKitServer {
1209    fn default() -> Self {
1210        Self::new()
1211    }
1212}
1213
1214/// Parse a date string in format "YYYY-MM-DD" or "YYYY-MM-DD HH:MM"
1215fn parse_datetime(s: &str) -> Result<DateTime<Local>, String> {
1216    // Try parsing with time first
1217    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    // Try parsing date only
1225    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    // ========================================================================
1246    // Authorization
1247    // ========================================================================
1248
1249    #[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    // ========================================================================
1290    // Reminders Tools
1291    // ========================================================================
1292
1293    #[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", &params.due_after)?;
1327        let due_before = parse_opt_date("due_before", &params.due_before)?;
1328        let completed_after = parse_opt_date("completed_after", &params.completed_after)?;
1329        let completed_before = parse_opt_date("completed_before", &params.completed_before)?;
1330
1331        // Resolve list_name into the slice the manager expects.
1332        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            // Fall back to all-reminders + post-filter for backward compat —
1350            // EventKit has no "all reminders in calendars" predicate that
1351            // also includes completed by default; fetch_all delegates to
1352            // fetch_reminders(None) which uses predicateForRemindersInCalendars.
1353            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        // Validate the list exists
1383        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: &params.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) = &params.alarms {
1430                    apply_alarms_reminder(&manager, &id, alarms);
1431                }
1432                if let Some(g) = &params.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) = &params.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        // Parse due date: Some("") means clear, Some(date) means set, None means no change
1469        let due_date = match &params.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 &params.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        // Map each Option<String> ("" means clear, otherwise set) to the
1506        // Option<Option<&str>> patch encoding (Some(None) = clear).
1507        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(&params.due_date_timezone);
1512
1513        // completion_date: same "" = clear, set = set, omitted = no change.
1514        let completion_date_patch: Option<Option<DateTime<Local>>> = match &params.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            &params.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) = &params.alarms {
1541                    apply_alarms_reminder(&manager, &id, alarms);
1542                }
1543                if let Some(recurrence) = &params.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(&params.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(&params.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(&params.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(&params.reminder_id) {
1603            Ok(_) => {
1604                let r = manager.get_reminder(&params.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(&params.reminder_id) {
1621            Ok(_) => {
1622                let r = manager.get_reminder(&params.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(&params.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(&params.reminder_id) {
1651            Ok(_) => Ok(Json(DeletedResponse {
1652                id: params.reminder_id,
1653            })),
1654            Err(e) => Err(mcp_err(&e)),
1655        }
1656    }
1657
1658    // ------------------------------------------------------------------------
1659    // Reminder posture setters (one field at a time, for fix-ups)
1660    //
1661    // NB: URL, plain location, and rich structuredLocation setters are
1662    // intentionally not exposed for reminders — iCloud silently drops those
1663    // mutations even though the EventKit APIs accept them. The geofence path
1664    // (location-based alarm) is the only iCloud-honored location write.
1665    // For events those same fields are first-class — see create_event /
1666    // update_event.
1667    // ------------------------------------------------------------------------
1668
1669    #[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(&params.reminder_id, tz)
1683            .map_err(|e| mcp_err(&e))?;
1684        let item = manager
1685            .get_reminder(&params.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(&params.reminder_id, geofence_ref)
1716            .map_err(|e| mcp_err(&e))?;
1717        let item = manager
1718            .get_reminder(&params.reminder_id)
1719            .map_err(|e| mcp_err(&e))?;
1720        Ok(Json(ReminderOutput::from_item(&item, &manager)))
1721    }
1722
1723    // ========================================================================
1724    // Calendar/Events Tools
1725    // ========================================================================
1726
1727    #[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(&params.availability).map_err(mcp_invalid)?;
1762        manager
1763            .set_event_availability(&params.event_id, availability)
1764            .map_err(|e| mcp_err(&e))?;
1765        let item = manager
1766            .get_event(&params.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(&params.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) = &params.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: &params.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) = &params.alarms {
1866                    apply_alarms_event(&manager, &id, alarms);
1867                }
1868                if let Some(recurrence) = &params.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(&params.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(&params.event_id) {
1905            Ok(e) => Ok(Json(EventOutput::from_item(&e, &manager))),
1906            Err(e) => Err(mcp_err(&e)),
1907        }
1908    }
1909
1910    // ========================================================================
1911    // Event Calendar Management
1912    // ========================================================================
1913
1914    #[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(&params.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(&params.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(&params.list_id) {
1951            Ok(()) => Ok(Json(DeletedResponse { id: params.list_id })),
1952            Err(e) => Err(mcp_err(&e)),
1953        }
1954    }
1955
1956    // ========================================================================
1957    // Sources
1958    // ========================================================================
1959
1960    #[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    // ========================================================================
1976    // Event Update Tool
1977    // ========================================================================
1978
1979    #[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        // Empty-string-clears convention, same as reminders side.
1998        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(&params.notes);
2003        let location_patch = opt_patch(&params.location);
2004        let url_patch = opt_patch(&params.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        // We only get Some(value) here — no clear path through serde because
2023        // null collapses into None for Option<T>. For an explicit clear,
2024        // callers can call update_event again with a future schema enhancement
2025        // (low-frequency need; matches reminder side).
2026        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            &params.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) = &params.alarms {
2049                    apply_alarms_event(&manager, &id, alarms);
2050                }
2051                if let Some(recurrence) = &params.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    // ========================================================================
2080    // Search Tools
2081    // ========================================================================
2082
2083    #[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    // ========================================================================
2154    // Batch Operations
2155    // ========================================================================
2156
2157    #[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 &params.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 &params.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 &params.reminder_ids {
2214            match manager.update_reminder(
2215                id,
2216                &crate::ReminderPatch {
2217                    calendar_title: Some(&params.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 &params.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 &params.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
2325/// Parse a RecurrenceParam into a RecurrenceRule.
2326fn 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) = &params.end_date {
2345        let dt = parse_datetime(date_str)?;
2346        crate::RecurrenceEndCondition::OnDate(dt)
2347    } else {
2348        crate::RecurrenceEndCondition::Never
2349    };
2350
2351    // Range validation at boundary. Apple raises opaque NSExceptions for
2352    // out-of-range values, so we catch them here with friendlier messages.
2353    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", &params.days_of_month, |v| {
2368        (-31..=31).contains(&v) && v != 0
2369    })?;
2370    check_range("months_of_year", &params.months_of_year, |v| {
2371        (1..=12).contains(&v)
2372    })?;
2373    check_range("weeks_of_year", &params.weeks_of_year, |v| {
2374        (-53..=53).contains(&v) && v != 0
2375    })?;
2376    check_range("days_of_year", &params.days_of_year, |v| {
2377        (-366..=366).contains(&v) && v != 0
2378    })?;
2379    check_range("set_positions", &params.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
2396/// Parse a date/time string, defaulting to today if only time is given.
2397fn parse_datetime_or_time(s: &str) -> Result<DateTime<Local>, String> {
2398    // Try full datetime or date first
2399    if let Ok(dt) = parse_datetime(s) {
2400        return Ok(dt);
2401    }
2402    // Try time-only: "HH:MM" → use today's date
2403    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
2417/// Apply alarms to a reminder, clearing existing ones first.
2418fn apply_alarms_reminder(manager: &RemindersManager, id: &str, alarms: &[AlarmParam]) {
2419    // Clear existing alarms
2420    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    // Add new alarms
2426    for param in alarms {
2427        let alarm = alarm_param_to_info(param);
2428        let _ = manager.add_alarm(id, &alarm);
2429    }
2430}
2431
2432/// Apply alarms to an event, clearing existing ones first.
2433fn 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
2445/// Convert an AlarmParam to an AlarmInfo.
2446fn 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        (&param.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// ============================================================================
2476// Prompts
2477// ============================================================================
2478
2479#[prompt_router]
2480impl EventKitServer {
2481    /// List all incomplete (not yet finished) reminders, optionally filtered by list name.
2482    #[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        // Filter by list name if provided
2496        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    /// List all reminder lists (calendars) available in macOS Reminders.
2536    #[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    /// Move a reminder to a different reminder list.
2565    #[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        // Find the destination calendar
2576        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    /// Create a new reminder with optional notes, priority, due date, and list.
2626    #[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// Implement the server handler
2682#[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
2700/// Serve the EventKit MCP server on any async read/write transport.
2701///
2702/// Used by the in-process gateway (via `DuplexStream`) and for testing.
2703/// The standalone binary uses [`run_mcp_server`] which wraps this with stdio.
2704pub 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
2714/// Run the EventKit MCP server on stdio transport.
2715///
2716/// This initializes logging to stderr (MCP uses stdout/stdin for protocol)
2717/// and starts the MCP server. Used by the standalone binary (`eventkit --mcp`).
2718pub 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
2730// ============================================================================
2731// Dump helpers — serialize objects to JSON for CLI debugging
2732// ============================================================================
2733
2734/// Dump a single reminder as pretty JSON (with alarms and recurrence rules).
2735pub 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
2742/// Dump every Objective-C `@property` on the reminder, its calendar, and that
2743/// calendar's source — using runtime reflection. Use this to discover native
2744/// fields not yet surfaced by [`crate::ReminderItem`].
2745///
2746/// `read_values` controls whether property values are read via KVC. Schema-
2747/// only (`false`) is always safe; reading values may surface NSExceptions and
2748/// — for a small denylisted set — could abort the process via C asserts.
2749pub 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
2754/// Probe a curated list of suspected-private selectors on a reminder
2755/// (`richLink`, `tags`, `structuredData`, etc.). Read-only, exception-safe.
2756pub fn dump_reminder_private(id: &str) -> Result<String, crate::EventKitError> {
2757    let manager = RemindersManager::new();
2758    manager.dump_reminder_private(id)
2759}
2760
2761/// Dump all reminders as pretty JSON (summary mode — no alarm/recurrence fetch).
2762pub 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
2780/// Dump a single event as pretty JSON (with alarms, recurrence, attendees).
2781pub 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
2788/// Dump upcoming events as pretty JSON.
2789pub 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
2796/// Dump all reminder lists as pretty JSON.
2797pub 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
2804/// Dump all event calendars as pretty JSON.
2805pub 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
2812/// Dump all sources as pretty JSON.
2813pub 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()); // case-sensitive
2837        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        // Catches accidental rename of the new fields on the input schema.
2854        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        // Update this whenever AuthorizationStatus gains a variant — that's
2890        // the whole point: a new variant breaks compilation here, and the
2891        // MCP `auth_status` response stays in sync with the source enum.
2892        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        // Denied (3) > Restricted (2) > NotDetermined (1) > granted (0).
2929        // When reminders=NotDetermined and events=Denied, hint should reflect
2930        // the Denied state.
2931        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        // The plain error message is just "Authorization denied"; mcp_err
2958        // wraps it with actionable guidance for the agent. If this test
2959        // breaks, the agent loses its diagnostic hint.
2960        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}