Skip to content

Commit d81847b

Browse files
committed
Add rrule param to create_event for recurring events, with integration tests
1 parent 32ce2f1 commit d81847b

2 files changed

Lines changed: 84 additions & 1 deletion

File tree

src/nc_mcp_server/tools/calendar.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ def _build_ical(
229229
location: str = "",
230230
status: str = "CONFIRMED",
231231
categories: list[str] | None = None,
232+
rrule: str = "",
232233
) -> str:
233234
"""Build a minimal iCalendar VEVENT string."""
234235
cal = ICal()
@@ -248,10 +249,29 @@ def _build_ical(
248249
event.add("status", status)
249250
if categories:
250251
event.add("categories", categories)
252+
if rrule:
253+
event.add("rrule", _parse_rrule(rrule))
251254
cal.add_component(event)
252255
return cal.to_ical().decode()
253256

254257

258+
def _parse_rrule(rrule_str: str) -> dict[str, list[Any]]:
259+
"""Parse an RRULE string like 'FREQ=WEEKLY;COUNT=4;BYDAY=MO,WE' into a dict."""
260+
result: dict[str, list[Any]] = {}
261+
for part in rrule_str.split(";"):
262+
if "=" not in part:
263+
continue
264+
key, val = part.split("=", 1)
265+
key = key.strip()
266+
if key == "UNTIL":
267+
result[key] = [datetime.fromisoformat(val.strip())]
268+
elif key in {"COUNT", "INTERVAL"}:
269+
result[key] = [int(val.strip())]
270+
else:
271+
result[key] = [v.strip() for v in val.split(",")]
272+
return result
273+
274+
255275
def _validate_status(status: str) -> str:
256276
valid = {"CONFIRMED", "TENTATIVE", "CANCELLED"}
257277
upper = status.upper()
@@ -424,6 +444,7 @@ async def create_event(
424444
location: str = "",
425445
status: str = "CONFIRMED",
426446
categories: str = "",
447+
rrule: str = "",
427448
) -> str:
428449
"""Create a new calendar event.
429450
@@ -440,6 +461,9 @@ async def create_event(
440461
location: Optional event location.
441462
status: Event status: "CONFIRMED" (default), "TENTATIVE", or "CANCELLED".
442463
categories: Optional comma-separated category names (e.g. "Work,Meeting").
464+
rrule: Optional recurrence rule in iCalendar RRULE format.
465+
Examples: "FREQ=DAILY;COUNT=5", "FREQ=WEEKLY;BYDAY=MO,WE,FR",
466+
"FREQ=MONTHLY;BYMONTHDAY=15;UNTIL=20261231T235959Z".
443467
444468
Returns:
445469
JSON object with the created event's uid and summary.
@@ -455,7 +479,7 @@ async def create_event(
455479
dtend = dtstart + timedelta(hours=1)
456480

457481
uid = str(uuid.uuid4())
458-
ical_data = _build_ical(uid, summary, dtstart, dtend, description, location, status_upper, cat_list)
482+
ical_data = _build_ical(uid, summary, dtstart, dtend, description, location, status_upper, cat_list, rrule)
459483
client = get_client()
460484
user = get_config().user
461485
path = _caldav_path(user, calendar_id, f"{uid}.ics")

tests/integration/test_calendar.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,65 @@ async def test_create_with_categories(self, nc_mcp: McpTestHelper) -> None:
170170

171171
await nc_mcp.call("delete_event", calendar_id=CAL_ID, event_uid=created["uid"])
172172

173+
@pytest.mark.asyncio
174+
async def test_create_recurring_weekly(self, nc_mcp: McpTestHelper) -> None:
175+
result = await nc_mcp.call(
176+
"create_event",
177+
calendar_id=CAL_ID,
178+
summary="mcp-test-weekly",
179+
start="2027-06-02T10:00:00Z",
180+
end="2027-06-02T11:00:00Z",
181+
rrule="FREQ=WEEKLY;COUNT=4",
182+
)
183+
created = json.loads(result)
184+
event = json.loads(await nc_mcp.call("get_event", calendar_id=CAL_ID, event_uid=created["uid"]))
185+
assert "rrule" in event
186+
assert "WEEKLY" in event["rrule"]
187+
assert "COUNT=4" in event["rrule"]
188+
189+
await nc_mcp.call("delete_event", calendar_id=CAL_ID, event_uid=created["uid"])
190+
191+
@pytest.mark.asyncio
192+
async def test_create_recurring_daily_with_until(self, nc_mcp: McpTestHelper) -> None:
193+
result = await nc_mcp.call(
194+
"create_event",
195+
calendar_id=CAL_ID,
196+
summary="mcp-test-daily",
197+
start="2027-07-01T09:00:00Z",
198+
end="2027-07-01T09:30:00Z",
199+
rrule="FREQ=DAILY;UNTIL=20270705T235959Z",
200+
)
201+
created = json.loads(result)
202+
events = json.loads(
203+
await nc_mcp.call(
204+
"get_events",
205+
calendar_id=CAL_ID,
206+
start="2027-07-01T00:00:00Z",
207+
end="2027-07-31T23:59:59Z",
208+
)
209+
)
210+
matching = [e for e in events if e["uid"] == created["uid"]]
211+
assert len(matching) >= 1
212+
213+
await nc_mcp.call("delete_event", calendar_id=CAL_ID, event_uid=created["uid"])
214+
215+
@pytest.mark.asyncio
216+
async def test_create_recurring_monthly_byday(self, nc_mcp: McpTestHelper) -> None:
217+
result = await nc_mcp.call(
218+
"create_event",
219+
calendar_id=CAL_ID,
220+
summary="mcp-test-monthly",
221+
start="2027-08-01T14:00:00Z",
222+
end="2027-08-01T15:00:00Z",
223+
rrule="FREQ=MONTHLY;BYMONTHDAY=1;COUNT=3",
224+
)
225+
created = json.loads(result)
226+
event = json.loads(await nc_mcp.call("get_event", calendar_id=CAL_ID, event_uid=created["uid"]))
227+
assert "MONTHLY" in event["rrule"]
228+
assert "BYMONTHDAY=1" in event["rrule"]
229+
230+
await nc_mcp.call("delete_event", calendar_id=CAL_ID, event_uid=created["uid"])
231+
173232
@pytest.mark.asyncio
174233
async def test_create_invalid_status_raises(self, nc_mcp: McpTestHelper) -> None:
175234
with pytest.raises((ToolError, ValueError)):

0 commit comments

Comments
 (0)