From 34f43904b7e8fc40096224433c368f6c47577e77 Mon Sep 17 00:00:00 2001 From: Arnd Jan Gulmans Date: Tue, 17 Mar 2026 11:02:55 +0100 Subject: [PATCH 1/2] Add attendees support to calendar events - Add attendees parameter (list of {email, name}) to create_event and edit_event - Return attendee details (name, email, status, role, type) in event responses - Uses EKAttendee private API via PyObjC for adding attendees - Read support uses public EKParticipant API Co-Authored-By: Claude Opus 4.6 (1M context) --- src/apple_eventkit_mcp/calendar_tools.py | 14 ++- src/apple_eventkit_mcp/eventkit_store.py | 104 ++++++++++++++++++++++- 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/src/apple_eventkit_mcp/calendar_tools.py b/src/apple_eventkit_mcp/calendar_tools.py index f7f73fa..2ca1de6 100644 --- a/src/apple_eventkit_mcp/calendar_tools.py +++ b/src/apple_eventkit_mcp/calendar_tools.py @@ -222,7 +222,8 @@ def calendar_create_event( notes: Optional[str] = None, url: Optional[str] = None, is_all_day: bool = False, - tags: Optional[list[str]] = None + tags: Optional[list[str]] = None, + attendees: Optional[list[dict]] = None ) -> dict: """Create a new calendar event. @@ -236,6 +237,7 @@ def calendar_create_event( url: Associated URL (optional) is_all_day: All-day event flag (default: false) tags: Tags to apply (optional) + attendees: List of attendee dicts with 'email' (required) and 'name' (optional) """ try: start = datetime.fromisoformat(start_date.replace("Z", "+00:00")) @@ -250,7 +252,8 @@ def calendar_create_event( notes=notes, url=url, is_all_day=is_all_day, - tags=tags + tags=tags, + attendees=attendees ) return { @@ -287,7 +290,8 @@ def calendar_edit_event( location: Optional[str] = None, notes: Optional[str] = None, url: Optional[str] = None, - tags: Optional[list[str]] = None + tags: Optional[list[str]] = None, + attendees: Optional[list[dict]] = None ) -> dict: """Edit an existing calendar event. @@ -301,6 +305,7 @@ def calendar_edit_event( notes: New notes (optional) url: New URL (optional) tags: New tags - replaces existing tags (optional) + attendees: List of attendee dicts with 'email' (required) and 'name' (optional) """ if span not in ("this_event", "future_events"): return { @@ -326,7 +331,8 @@ def calendar_edit_event( location=location, notes=notes, url=url, - tags=tags + tags=tags, + attendees=attendees ) return { diff --git a/src/apple_eventkit_mcp/eventkit_store.py b/src/apple_eventkit_mcp/eventkit_store.py index 89510a9..5f0b597 100644 --- a/src/apple_eventkit_mcp/eventkit_store.py +++ b/src/apple_eventkit_mcp/eventkit_store.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from typing import Optional +import objc import EventKit from Cocoa import NSDate, NSDateComponents, NSCalendar, NSURL @@ -117,7 +118,8 @@ def create_event( notes: Optional[str] = None, url: Optional[str] = None, is_all_day: bool = False, - tags: Optional[list[str]] = None + tags: Optional[list[str]] = None, + attendees: Optional[list[dict]] = None ) -> dict: """Create a new calendar event.""" require_calendar_permission() @@ -149,6 +151,10 @@ def create_event( final_notes = merge_notes_with_tags(notes_with_attribution, tags) event.setNotes_(final_notes) + # Add attendees + if attendees: + self._add_attendees_to_event(event, attendees) + # Save success, error = self._store.saveEvent_span_error_( event, EventKit.EKSpanThisEvent, None @@ -169,7 +175,8 @@ def edit_event( location: Optional[str] = None, notes: Optional[str] = None, url: Optional[str] = None, - tags: Optional[list[str]] = None + tags: Optional[list[str]] = None, + attendees: Optional[list[dict]] = None ) -> dict: """Edit an existing event.""" require_calendar_permission() @@ -205,6 +212,10 @@ def edit_event( final_notes = merge_notes_with_tags(clean_notes, existing_tags) event.setNotes_(final_notes if final_notes else None) + # Add attendees (replaces existing attendees) + if attendees is not None: + self._add_attendees_to_event(event, attendees) + # Determine span ek_span = ( EventKit.EKSpanFutureEvents @@ -609,6 +620,29 @@ def _event_to_dict(self, event: EventKit.EKEvent) -> dict: notes = event.notes() or "" clean_notes, tags = decode_tags(notes) + # Extract attendees + attendees = [] + if event.attendees(): + for participant in event.attendees(): + attendee_dict = { + "name": participant.name() if participant.name() else None, + "email": None, + "status": self._participant_status_to_str(participant.participantStatus()), + "role": self._participant_role_to_str(participant.participantRole()), + "type": self._participant_type_to_str(participant.participantType()), + } + # Email from emailAddress method (available on EKAttendee/EKParticipant) + try: + attendee_dict["email"] = participant.emailAddress() + except Exception: + # Fall back to URL-based email extraction + url = participant.URL() + if url: + url_str = str(url) + if url_str.startswith("mailto:"): + attendee_dict["email"] = url_str[7:] + attendees.append(attendee_dict) + return { "id": event.calendarItemIdentifier(), "external_id": event.calendarItemExternalIdentifier(), @@ -622,6 +656,7 @@ def _event_to_dict(self, event: EventKit.EKEvent) -> dict: "is_all_day": event.isAllDay(), "url": str(event.URL()) if event.URL() else None, "has_recurrence": event.hasRecurrenceRules(), + "attendees": attendees, } def _reminder_to_dict(self, reminder: EventKit.EKReminder) -> dict: @@ -653,6 +688,71 @@ def _reminder_to_dict(self, reminder: EventKit.EKReminder) -> dict: "completion_date": self._nsdate_to_iso(reminder.completionDate()), } + def _add_attendees_to_event( + self, event: EventKit.EKEvent, attendees: list[dict] + ) -> None: + """Add attendees to an event using the private EKAttendee API. + + Each attendee dict should have 'email' (required) and optionally 'name'. + Uses the undocumented EKAttendee class — works on macOS with CalDAV/Exchange calendars. + """ + try: + EKAttendee = objc.lookUpClass("EKAttendee") + except objc.nosuchclass_error: + raise Exception( + "EKAttendee class not available on this system. " + "Adding attendees programmatically is not supported." + ) + + for attendee_info in attendees: + email = attendee_info.get("email") + if not email: + continue + name = attendee_info.get("name", email) + attendee = EKAttendee.alloc().initWithName_emailAddress_phoneNumber_url_( + name, email, None, None + ) + event.addAttendee_(attendee) + + @staticmethod + def _participant_status_to_str(status: int) -> str: + """Convert EKParticipantStatus to string.""" + status_map = { + 0: "unknown", + 1: "pending", + 2: "accepted", + 3: "declined", + 4: "tentative", + 5: "delegated", + 6: "completed", + 7: "in_process", + } + return status_map.get(status, "unknown") + + @staticmethod + def _participant_role_to_str(role: int) -> str: + """Convert EKParticipantRole to string.""" + role_map = { + 0: "unknown", + 1: "required", + 2: "optional", + 3: "chair", + 4: "non_participant", + } + return role_map.get(role, "unknown") + + @staticmethod + def _participant_type_to_str(ptype: int) -> str: + """Convert EKParticipantType to string.""" + type_map = { + 0: "unknown", + 1: "person", + 2: "room", + 3: "resource", + 4: "group", + } + return type_map.get(ptype, "unknown") + def _find_calendar_unlocked(self, name: str) -> Optional[EventKit.EKCalendar]: """Find calendar by name without acquiring lock (caller must hold lock).""" calendars = self._store.calendarsForEntityType_(EventKit.EKEntityTypeEvent) From 94288454cbb39bfb69126e670a89eb72c3e32671 Mon Sep 17 00:00:00 2001 From: Arnd Jan Gulmans Date: Tue, 17 Mar 2026 11:07:19 +0100 Subject: [PATCH 2/2] Use setAttendees_ instead of addAttendee_ to replace full list addAttendee_ appends to existing attendees, causing duplicates when editing events that already have attendees. setAttendees_ replaces the entire list, which is the correct behavior for the edit flow. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/apple_eventkit_mcp/eventkit_store.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/apple_eventkit_mcp/eventkit_store.py b/src/apple_eventkit_mcp/eventkit_store.py index 5f0b597..cf430ab 100644 --- a/src/apple_eventkit_mcp/eventkit_store.py +++ b/src/apple_eventkit_mcp/eventkit_store.py @@ -704,6 +704,7 @@ def _add_attendees_to_event( "Adding attendees programmatically is not supported." ) + new_attendees = [] for attendee_info in attendees: email = attendee_info.get("email") if not email: @@ -712,7 +713,10 @@ def _add_attendees_to_event( attendee = EKAttendee.alloc().initWithName_emailAddress_phoneNumber_url_( name, email, None, None ) - event.addAttendee_(attendee) + new_attendees.append(attendee) + + # Use setAttendees_ to replace the full list (addAttendee_ only appends) + event.setAttendees_(new_attendees) @staticmethod def _participant_status_to_str(status: int) -> str: