From 1100396c79231097a5385bd2443374463f004ab1 Mon Sep 17 00:00:00 2001 From: Lago Date: Mon, 26 Jan 2026 16:25:39 +0100 Subject: [PATCH] feat: Implement CalDAV client and FastMCP server exposing calendar event management tools. --- src/__pycache__/caldav_client.cpython-314.pyc | Bin 8228 -> 8983 bytes src/__pycache__/server.cpython-314.pyc | Bin 7489 -> 9389 bytes src/caldav_client.py | 24 ++++++--- src/server.py | 50 +++++++++++++++--- test_rrule.py | 7 +++ test_rrule_fix.py | 18 +++++++ 6 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 test_rrule.py create mode 100644 test_rrule_fix.py diff --git a/src/__pycache__/caldav_client.cpython-314.pyc b/src/__pycache__/caldav_client.cpython-314.pyc index 32b60119775d83589cfa089d3e4eeb11b4be27de..eea7ef1b7b26e22f16b47c94d750073cfd2e9669 100644 GIT binary patch delta 3121 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 A>i_@% diff --git a/src/__pycache__/server.cpython-314.pyc b/src/__pycache__/server.cpython-314.pyc index 4977e581bae906ae4e32fe41c575fb3399e16b2b..6928bfa98abe6f4a25ba68bdac7d73a8b1c47fdc 100644 GIT binary patch delta 3304 zcmai0YitwQ6}~g$_cP;QJ2p--33-{k2pd94NND23B0wGt*~wB2!D~-~!HL~FW3sec zIjjEIAX*wat6f&DN-Awts&=ckR24tAEwH;4sV%}U#B&)7DswmGdn-5deP9;rwY&`2lyvvJ{ z6>^m_N!3yTMc zC;P5#`jxA6kUFH?rY}_1^Cr1sup&VvP#;r`5>&88>0$Ez`cQ8ql!_-7<|1RE$@Wk* zIXoytBpi#}I-vWP$)bZL8Tg*=QM&9+U36gwggWV6D9B(dj8!wnnWQ))$r#&U9Hg;> zh;{KqOjfe4K=u#}B|oqw^f@w9>=VMi|I#LD-s{#z*ahlX@du!bkRCp5=U+f)2?=O& zyOvl0@u>9EX{r;YX@ROh;e!aJ8J-fD17I=7pezcYqi`~TdEpY&8LGSZUyIP<5uO(G zWNIAF6hMOhg|q1L#`EtsWLy_8NSQUyLR*qGSN0CXNuUAof|-GT@B@`T2}OVU(L#{D@UGK5$oBsAsf4NZ0kx&jwgrFuH-5NgJ3TQ18;}Dye+URhK;qv!ye@ zNT^g(T~7#mkJ%*6&th8hfuuE`6h`Z3i>`4bV)NupQp7I z3;$*;o|QBv_!tN}-P8M&GU=#y3i{@vI(`J=K>4bpRatlJP$mp!%BcLnvAZ;4Ok-^@ zuCs211ktx9- zHnbPQj6)HUavB*>+(&jvTX|i?i4X}Cpebu5ZRS0mWaddRw~*lvRaeRKw{TH&34 z%97wK-dZnrLXsP$ zjLKr1i%MJ^I#RI1qQ!JM26{Q3fBD!52gm^c)W(S&a~*A5eV8Bd9q%6=96ffjFEsJ; z_*8$_p85_8Zh*fTR~jmUzMu&cIKc!Xa3e4P8`uHwS0WXGtPOKSPEr{664pl;paR1E zgh84jOl}6Z0E3{^;ESM@YQUnDNy@ke6cX`lQ-R323(1Iw$V9>*Dv;!y*c;6%i+h*%y`~#IsA`3y>Fdb^{>0z?&y{+k6G^q+pxwqte(F8%KfkB*tumx zp5<0OtBRiujs)RcE5@M#bbat3@O$nS!UwFOI_5*OJrrO*^iZT9&;!?Sx0K`rh_xg) zQOaGWUb(@(V=ZSojO_`J@Y~A#>sO>aEwp+C4Ky$uo)?#BOlZb!8JrF0uLqc8* zOadGqQeJn}PF;Ym^kZNI2O0?u03$d6&94j12j@PLemk#8*3X_>PbB|xwlIR+AEZYK z1RSBq2m}n$;{XAT%D-K{voIeNA?7Fp%`s+@0ey%$URV-Dv&;g~q!>&zvy4>MWSJqI zc8mge5WpxA$!qYL?rEEb9a*0CqO?h1%IOaV@RFG7&~#7ft%1a6(q=*T0-NwNHWtBH zwkf~}xgL!KV=1?_)vQGrA2FTj*> zagpH4-ZHRoEvdAeNG(K@V6CA@tQ^qb2;$93_@4e;`L?bxAZ(QsP)_iW=5bcLJ$h0P zHC4|p1X2p5n#0IjA1S&MDva{nGnOyC0u{venOT@gDg$~d9Mo+=7aoMYRVFDe1R0f* zRA)k(DI)L8Y%)5h(s5ZLXKZRJmIIDLIiR^_CN8TDnKVJa87<{H4klAE@bf^&vsT|g za2{y_)>%T2Sl}qyzYXsVY^MGdwCSQl%Kj|CVc^s_bWR+fvlr|z+u?!3(|j#n2;FMuo#D+ry8Y9 zEG9})0fh-N6j8WP6BTio{0b)QJbcm>U;q_08V*ltK)gfW^&i@7_I&O^eIwMhfz{pX zl|A))(;qw1Uf|Itu8J!KGW+a$fsfo&QXknRjyLtAyME&ci$3bNk2si*?G)jxF>GQ!u4)W3%pVv%^#2cuxYOnU delta 1541 zcmai!O>7%Q6vt;~y&wKa>|Hmu<2a2|I|)wOIF22+P8AX-4HSt+!GKdlQpGq{V__%T zU8j{=g@QN`2_Xf|1#v*F#GyzO3Bnr__%Q3&DyS93B;59 ze{bf!nKwK8^WJ`8e{R^`oR54L>Ra~9)Ps$v(N#vLu*ltfk@uH>C-OSx%`jMzOfT&?dwbLQy;&B$K*HVNSb-6AhK zeJQErNILR3k4|&#D33!vI?jFD7Y#2i7jxl4=Hhbr%yM?6m|H68;m7l(Tqb;OV*12H zrf?>i4(nPryb&|~B3W$EgoEGVJYBM9i?UTd0aDk``@fkOYt)%}VvX1hmkKkPoR+ikr38XP=Sk*T`0^ zkamfcACVIkbNp7XkHj- z>^xc*y>`TR-K_c9ppVIMCMTHmGwEUS2*^D_%jqkHl6DjZx*4RKnIBAZu%dywjIsh9 z1TNZxY|88L@9aYrlgLNzJKGMNp(@Ovmh6P282Na?&GSvv$&;yQZ#1v z=NI?$g|Fw?h<@O1orJ325n3Px0Z?xaEhox+VpVBX{s}0(mcz`8Q&7)>mZ}MPl7PS>j_vTvAyVgrjqY!fCk@Y7!c0T#XViY=}vo&%+8N9npz{7n4n>F4&8 zi!^aGI>3lu7n4!+RtL!DDUfmmGmTouU0nImrg53OYsC=XmHBZOy?Jy&FwSaxUGSxi zfsRixj?!QKE=vM7)0n8#-nnVT_fW_*1r} str: + def create_event(self, calendar_name: str, summary: str, start_time: Union[datetime, date], end_time: Union[datetime, date], description: str = "", recurrence: Optional[Dict] = None) -> str: calendar = self._get_calendar(calendar_name) event = calendar.save_event( dtstart=start_time, dtend=end_time, summary=summary, - description=description + description=description, + rrule=recurrence ) # Re-parse to get the UID, though library might provide it ical = icalendar.Calendar.from_ical(event.data) @@ -86,7 +87,7 @@ class CalDAVClient: return str(component.get("uid")) return "Event Created (UID Unknown)" - def update_event(self, calendar_name: str, event_uid: str, summary: Optional[str] = None, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, description: Optional[str] = None) -> bool: + def update_event(self, calendar_name: str, event_uid: str, summary: Optional[str] = None, start_time: Optional[Union[datetime, date]] = None, end_time: Optional[Union[datetime, date]] = None, description: Optional[str] = None, recurrence: Optional[Dict] = None) -> bool: calendar = self._get_calendar(calendar_name) event = calendar.event_by_uid(event_uid) @@ -103,10 +104,19 @@ class CalDAVClient: component["description"] = description changed = True if start_time: - component["dtstart"] = icalendar.vDatetime(start_time) + if isinstance(start_time, datetime): + component["dtstart"] = icalendar.vDatetime(start_time) + else: + component["dtstart"] = icalendar.vDate(start_time) changed = True if end_time: - component["dtend"] = icalendar.vDatetime(end_time) + if isinstance(end_time, datetime): + component["dtend"] = icalendar.vDatetime(end_time) + else: + component["dtend"] = icalendar.vDate(end_time) + changed = True + if recurrence: + component["rrule"] = icalendar.vRecur(recurrence) changed = True if changed: diff --git a/src/server.py b/src/server.py index e44ec95..c9745b5 100644 --- a/src/server.py +++ b/src/server.py @@ -60,8 +60,19 @@ def list_events(calendar_name: Optional[str] = None, start_date: Optional[str] = except Exception as e: return f"Error listing events: {str(e)}" +def parse_rrule(rrule_str: str) -> dict: + parts = rrule_str.split(';') + rrule = {} + for part in parts: + if '=' in part: + key, value = part.split('=', 1) + if ',' in value: + value = value.split(',') + rrule[key] = value + return rrule + @mcp.tool() -def create_event(calendar_name: str, summary: str, start_time: str, end_time: str, description: str = "") -> str: +def create_event(calendar_name: str, summary: str, start_time: str, end_time: str, description: str = "", all_day: bool = False, recurrence: Optional[str] = None) -> str: """ Creates a new event. @@ -71,21 +82,29 @@ def create_event(calendar_name: str, summary: str, start_time: str, end_time: st start_time: Start time (ISO format). end_time: End time (ISO format). description: Event description. + all_day: Set to True for all-day events (start/end time will be treated as dates). + recurrence: RRULE string, e.g., "FREQ=DAILY;COUNT=10". """ if not client: return "Error: CalDAV client not initialized" try: - dt_start = datetime.fromisoformat(start_time) - dt_end = datetime.fromisoformat(end_time) + if all_day: + dt_start = datetime.fromisoformat(start_time).date() + dt_end = datetime.fromisoformat(end_time).date() + else: + dt_start = datetime.fromisoformat(start_time) + dt_end = datetime.fromisoformat(end_time) - result = client.create_event(calendar_name, summary, dt_start, dt_end, description) + rrule_dict = parse_rrule(recurrence) if recurrence else None + + result = client.create_event(calendar_name, summary, dt_start, dt_end, description, rrule_dict) return f"Event created: {result}" except Exception as e: return f"Error creating event: {str(e)}" @mcp.tool() -def update_event(calendar_name: str, event_uid: str, summary: Optional[str] = None, start_time: Optional[str] = None, end_time: Optional[str] = None, description: Optional[str] = None) -> str: +def update_event(calendar_name: str, event_uid: str, summary: Optional[str] = None, start_time: Optional[str] = None, end_time: Optional[str] = None, description: Optional[str] = None, all_day: Optional[bool] = None, recurrence: Optional[str] = None) -> str: """ Updates an existing event. @@ -96,15 +115,30 @@ def update_event(calendar_name: str, event_uid: str, summary: Optional[str] = No start_time: New start time (ISO format, optional). end_time: New end time (ISO format, optional). description: New description (optional). + all_day: True/False to explicitly set type. If None, infers from input format (YYYY-MM-DD -> date). + recurrence: New RRULE string (optional). """ if not client: return "Error: CalDAV client not initialized" try: - dt_start = datetime.fromisoformat(start_time) if start_time else None - dt_end = datetime.fromisoformat(end_time) if end_time else None + def parse_input_dt(s, is_all_day_flag): + dt = datetime.fromisoformat(s) + if is_all_day_flag is True: + return dt.date() + if is_all_day_flag is False: + return dt + # Inference: if length is 10 (YYYY-MM-DD), assume date + if len(s) == 10: + return dt.date() + return dt + + dt_start = parse_input_dt(start_time, all_day) if start_time else None + dt_end = parse_input_dt(end_time, all_day) if end_time else None - success = client.update_event(calendar_name, event_uid, summary, dt_start, dt_end, description) + rrule_dict = parse_rrule(recurrence) if recurrence else None + + success = client.update_event(calendar_name, event_uid, summary, dt_start, dt_end, description, rrule_dict) return "Event updated successfully" if success else "Event update failed (or no changes made)" except Exception as e: return f"Error updating event: {str(e)}" diff --git a/test_rrule.py b/test_rrule.py new file mode 100644 index 0000000..6c90dca --- /dev/null +++ b/test_rrule.py @@ -0,0 +1,7 @@ +from icalendar import vRecur + +rrule_dict_str = {'FREQ': 'WEEKLY', 'BYDAY': 'MO,WE'} +rrule_dict_list = {'FREQ': 'WEEKLY', 'BYDAY': ['MO', 'WE']} + +print(f"String input result: {vRecur(rrule_dict_str).to_ical().decode('utf-8')}") +print(f"List input result: {vRecur(rrule_dict_list).to_ical().decode('utf-8')}") diff --git a/test_rrule_fix.py b/test_rrule_fix.py new file mode 100644 index 0000000..316d9ed --- /dev/null +++ b/test_rrule_fix.py @@ -0,0 +1,18 @@ +from icalendar import vRecur + +def parse_rrule(rrule_str: str) -> dict: + parts = rrule_str.split(';') + rrule = {} + for part in parts: + if '=' in part: + key, value = part.split('=', 1) + # Fix: split commas into list + if ',' in value: + value = value.split(',') + rrule[key] = value + return rrule + +rrule_str = "FREQ=WEEKLY;BYDAY=MO,WE" +rrule_dict = parse_rrule(rrule_str) +print(f"Parsed dict: {rrule_dict}") +print(f"vRecur result: {vRecur(rrule_dict).to_ical().decode('utf-8')}")