Skip to content

Commit 32ce2f1

Browse files
committed
Add categories param to create_event/update_event, add rrule/categories edge case tests
1 parent ce3b123 commit 32ce2f1

2 files changed

Lines changed: 129 additions & 18 deletions

File tree

src/nc_mcp_server/tools/calendar.py

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ def _build_ical(
228228
description: str = "",
229229
location: str = "",
230230
status: str = "CONFIRMED",
231+
categories: list[str] | None = None,
231232
) -> str:
232233
"""Build a minimal iCalendar VEVENT string."""
233234
cal = ICal()
@@ -245,6 +246,8 @@ def _build_ical(
245246
event.add("location", location)
246247
if status:
247248
event.add("status", status)
249+
if categories:
250+
event.add("categories", categories)
248251
cal.add_component(event)
249252
return cal.to_ical().decode()
250253

@@ -257,6 +260,13 @@ def _validate_status(status: str) -> str:
257260
return upper
258261

259262

263+
def _set_prop(component: Any, name: str, value: Any, clear_if_empty: bool = False) -> None:
264+
component.pop(name, None)
265+
if clear_if_empty and not value:
266+
return
267+
component.add(name.lower(), value)
268+
269+
260270
def _apply_event_updates(
261271
component: Any,
262272
summary: str | None,
@@ -265,30 +275,24 @@ def _apply_event_updates(
265275
description: str | None,
266276
location: str | None,
267277
status: str | None,
278+
categories: list[str] | None = None,
268279
) -> None:
269280
if summary is not None:
270-
component.pop("SUMMARY", None)
271-
component.add("summary", summary)
281+
_set_prop(component, "SUMMARY", summary)
272282
if start is not None:
273-
component.pop("DTSTART", None)
274-
component.add("dtstart", _parse_dt(start, _is_all_day(component.get("DTSTART"))))
283+
_set_prop(component, "DTSTART", _parse_dt(start, _is_all_day(component.get("DTSTART"))))
275284
if end is not None:
276285
ref = component.get("DTEND") or component.get("DTSTART")
277-
component.pop("DTEND", None)
278-
component.add("dtend", _parse_dt(end, _is_all_day(ref)))
286+
_set_prop(component, "DTEND", _parse_dt(end, _is_all_day(ref)))
279287
if description is not None:
280-
component.pop("DESCRIPTION", None)
281-
if description:
282-
component.add("description", description)
288+
_set_prop(component, "DESCRIPTION", description, clear_if_empty=True)
283289
if location is not None:
284-
component.pop("LOCATION", None)
285-
if location:
286-
component.add("location", location)
290+
_set_prop(component, "LOCATION", location, clear_if_empty=True)
287291
if status is not None:
288-
component.pop("STATUS", None)
289-
component.add("status", status)
290-
component.pop("DTSTAMP", None)
291-
component.add("dtstamp", datetime.now(UTC))
292+
_set_prop(component, "STATUS", status)
293+
if categories is not None:
294+
_set_prop(component, "CATEGORIES", categories, clear_if_empty=True)
295+
_set_prop(component, "DTSTAMP", datetime.now(UTC))
292296

293297

294298
async def _find_event(calendar_id: str, event_uid: str) -> tuple[str, str, str]:
@@ -419,6 +423,7 @@ async def create_event(
419423
description: str = "",
420424
location: str = "",
421425
status: str = "CONFIRMED",
426+
categories: str = "",
422427
) -> str:
423428
"""Create a new calendar event.
424429
@@ -434,11 +439,13 @@ async def create_event(
434439
description: Optional event description/notes.
435440
location: Optional event location.
436441
status: Event status: "CONFIRMED" (default), "TENTATIVE", or "CANCELLED".
442+
categories: Optional comma-separated category names (e.g. "Work,Meeting").
437443
438444
Returns:
439445
JSON object with the created event's uid and summary.
440446
"""
441447
status_upper = _validate_status(status)
448+
cat_list = [c.strip() for c in categories.split(",") if c.strip()] if categories else None
442449
dtstart = _parse_dt(start, all_day)
443450
if end:
444451
dtend = _parse_dt(end, all_day)
@@ -448,7 +455,7 @@ async def create_event(
448455
dtend = dtstart + timedelta(hours=1)
449456

450457
uid = str(uuid.uuid4())
451-
ical_data = _build_ical(uid, summary, dtstart, dtend, description, location, status_upper)
458+
ical_data = _build_ical(uid, summary, dtstart, dtend, description, location, status_upper, cat_list)
452459
client = get_client()
453460
user = get_config().user
454461
path = _caldav_path(user, calendar_id, f"{uid}.ics")
@@ -474,6 +481,7 @@ async def update_event(
474481
description: str | None = None,
475482
location: str | None = None,
476483
status: str | None = None,
484+
categories: str | None = None,
477485
) -> str:
478486
"""Update an existing calendar event. Only provided fields are changed.
479487
@@ -489,16 +497,20 @@ async def update_event(
489497
description: New description. Pass "" to clear.
490498
location: New location. Pass "" to clear.
491499
status: New status: "CONFIRMED", "TENTATIVE", or "CANCELLED".
500+
categories: New categories as comma-separated string. Pass "" to clear.
492501
493502
Returns:
494503
Confirmation message with the updated event UID.
495504
"""
496505
validated_status = _validate_status(status) if status is not None else None
506+
cat_list: list[str] | None = None
507+
if categories is not None:
508+
cat_list = [c.strip() for c in categories.split(",") if c.strip()] if categories else []
497509
href, etag, ical_data = await _find_event(calendar_id, event_uid)
498510
cal = ICal.from_ical(ical_data)
499511
for component in cal.walk():
500512
if component.name == "VEVENT":
501-
_apply_event_updates(component, summary, start, end, description, location, validated_status)
513+
_apply_event_updates(component, summary, start, end, description, location, validated_status, cat_list)
502514
break
503515

504516
client = get_client()

tests/integration/test_calendar.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import contextlib
44
import json
5+
import uuid
56

67
import pytest
78
from mcp.server.fastmcp.exceptions import ToolError
@@ -151,6 +152,24 @@ async def test_create_defaults_end_timed(self, nc_mcp: McpTestHelper) -> None:
151152

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

155+
@pytest.mark.asyncio
156+
async def test_create_with_categories(self, nc_mcp: McpTestHelper) -> None:
157+
result = await nc_mcp.call(
158+
"create_event",
159+
calendar_id=CAL_ID,
160+
summary="mcp-test-cats",
161+
start="2026-08-05T10:00:00Z",
162+
categories="Work, Meeting, Important",
163+
)
164+
created = json.loads(result)
165+
event = json.loads(await nc_mcp.call("get_event", calendar_id=CAL_ID, event_uid=created["uid"]))
166+
assert "categories" in event
167+
assert "Work" in event["categories"]
168+
assert "Meeting" in event["categories"]
169+
assert "Important" in event["categories"]
170+
171+
await nc_mcp.call("delete_event", calendar_id=CAL_ID, event_uid=created["uid"])
172+
154173
@pytest.mark.asyncio
155174
async def test_create_invalid_status_raises(self, nc_mcp: McpTestHelper) -> None:
156175
with pytest.raises((ToolError, ValueError)):
@@ -383,6 +402,28 @@ async def test_update_status(self, nc_mcp: McpTestHelper) -> None:
383402

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

405+
@pytest.mark.asyncio
406+
async def test_update_categories(self, nc_mcp: McpTestHelper) -> None:
407+
created = json.loads(
408+
await nc_mcp.call(
409+
"create_event",
410+
calendar_id=CAL_ID,
411+
summary="mcp-test-update-cats",
412+
start="2026-12-22T10:00:00Z",
413+
)
414+
)
415+
await nc_mcp.call("update_event", calendar_id=CAL_ID, event_uid=created["uid"], categories="Personal,Travel")
416+
event = json.loads(await nc_mcp.call("get_event", calendar_id=CAL_ID, event_uid=created["uid"]))
417+
assert "categories" in event
418+
assert "Personal" in event["categories"]
419+
assert "Travel" in event["categories"]
420+
421+
await nc_mcp.call("update_event", calendar_id=CAL_ID, event_uid=created["uid"], categories="")
422+
event = json.loads(await nc_mcp.call("get_event", calendar_id=CAL_ID, event_uid=created["uid"]))
423+
assert "categories" not in event or event.get("categories") == []
424+
425+
await nc_mcp.call("delete_event", calendar_id=CAL_ID, event_uid=created["uid"])
426+
386427
@pytest.mark.asyncio
387428
async def test_update_nonexistent_raises(self, nc_mcp: McpTestHelper) -> None:
388429
with pytest.raises(ToolError, match="not found"):
@@ -526,6 +567,64 @@ async def test_update_preserves_unchanged_fields(self, nc_mcp: McpTestHelper) ->
526567

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

570+
@pytest.mark.asyncio
571+
async def test_event_with_categories_raw(self, nc_mcp: McpTestHelper) -> None:
572+
uid = f"mcp-test-cats-{uuid.uuid4()}"
573+
ical = (
574+
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n"
575+
"BEGIN:VEVENT\r\n"
576+
f"UID:{uid}\r\n"
577+
"DTSTART:20270601T100000Z\r\n"
578+
"DTEND:20270601T110000Z\r\n"
579+
"SUMMARY:mcp-test-categories\r\n"
580+
"CATEGORIES:Work,Meeting\r\n"
581+
"DTSTAMP:20260330T000000Z\r\n"
582+
"END:VEVENT\r\nEND:VCALENDAR\r\n"
583+
)
584+
user = nc_mcp.client._config.user
585+
path = f"calendars/{user}/{CAL_ID}/{uid}.ics"
586+
await nc_mcp.client.dav_request(
587+
"PUT",
588+
path,
589+
body=ical,
590+
headers={"Content-Type": "text/calendar; charset=utf-8"},
591+
)
592+
event = json.loads(await nc_mcp.call("get_event", calendar_id=CAL_ID, event_uid=uid))
593+
assert "categories" in event
594+
assert "Work" in event["categories"]
595+
assert "Meeting" in event["categories"]
596+
597+
await nc_mcp.call("delete_event", calendar_id=CAL_ID, event_uid=uid)
598+
599+
@pytest.mark.asyncio
600+
async def test_event_with_rrule(self, nc_mcp: McpTestHelper) -> None:
601+
uid = f"mcp-test-rrule-{uuid.uuid4()}"
602+
ical = (
603+
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n"
604+
"BEGIN:VEVENT\r\n"
605+
f"UID:{uid}\r\n"
606+
"DTSTART:20270701T090000Z\r\n"
607+
"DTEND:20270701T100000Z\r\n"
608+
"SUMMARY:mcp-test-rrule\r\n"
609+
"RRULE:FREQ=WEEKLY;COUNT=4\r\n"
610+
"DTSTAMP:20260330T000000Z\r\n"
611+
"END:VEVENT\r\nEND:VCALENDAR\r\n"
612+
)
613+
user = nc_mcp.client._config.user
614+
path = f"calendars/{user}/{CAL_ID}/{uid}.ics"
615+
await nc_mcp.client.dav_request(
616+
"PUT",
617+
path,
618+
body=ical,
619+
headers={"Content-Type": "text/calendar; charset=utf-8"},
620+
)
621+
event = json.loads(await nc_mcp.call("get_event", calendar_id=CAL_ID, event_uid=uid))
622+
assert "rrule" in event
623+
assert "WEEKLY" in event["rrule"]
624+
assert "COUNT=4" in event["rrule"]
625+
626+
await nc_mcp.call("delete_event", calendar_id=CAL_ID, event_uid=uid)
627+
529628
@pytest.mark.asyncio
530629
async def test_create_event_with_no_timezone(self, nc_mcp: McpTestHelper) -> None:
531630
created = json.loads(

0 commit comments

Comments
 (0)