diff --git a/docs/calendar.md b/docs/calendar.md index c049495a..df79f4d6 100644 --- a/docs/calendar.md +++ b/docs/calendar.md @@ -20,7 +20,7 @@ The server provides comprehensive calendar integration through CalDAV, enabling you to: -- List all available calendars +- List all available calendars, including external read-only subscriptions - Create, read, update, and delete calendar events - Handle recurring events with RRULE support - Manage event reminders and notifications @@ -31,7 +31,10 @@ The server provides comprehensive calendar integration through CalDAV, enabling **Usage Examples:** ```python -# List available calendars +# List available calendars. External subscriptions (webcal/ICS feeds) are +# included and reported with read_only=True and a `source` URL pointing at the +# upstream feed. Their events are readable through the normal event tools, but +# attempts to modify them will be rejected by Nextcloud. calendars = await nc_calendar_list_calendars() # Create a simple event diff --git a/nextcloud_mcp_server/client/calendar.py b/nextcloud_mcp_server/client/calendar.py index 11c3f1c1..1f0ddc60 100644 --- a/nextcloud_mcp_server/client/calendar.py +++ b/nextcloud_mcp_server/client/calendar.py @@ -80,11 +80,20 @@ class CalendarClient: elif token is not None: auth_kwargs = {"password": token, "auth_type": "bearer"} - # AsyncDAVClient needs the full base URL for proper URL construction + # AsyncDAVClient needs the full base URL for proper URL construction. + # + # The X-NC-CalDAV-Webcal-Caching header makes Nextcloud expose external + # subscriptions (webcal/ICS feeds) as regular, queryable calendars + # (CachedSubscription) instead of opaque cs:subscribed collections, so + # their events become readable through the normal event/search tools — + # the same mechanism desktop clients (Evolution/KDE) rely on (issue #830). + # list_calendars() overrides this header to "Off" on its own PROPFIND so + # it can still detect subscriptions and flag them read-only. self._dav_client = AsyncDAVClient( url=f"{base_url}/remote.php/dav/", username=auth_username, ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to niquests which accepts SSLContext + headers={"X-NC-CalDAV-Webcal-Caching": "On"}, **auth_kwargs, ) self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/" @@ -182,25 +191,46 @@ class CalendarClient: # ============= Calendar Operations ============= async def list_calendars(self) -> list[dict[str, Any]]: - """List all available calendars for the user.""" + """List all available calendars for the user. + + Returns both regular calendars and external read-only subscriptions + (webcal/ICS feeds). Subscriptions are reported with ``read_only=True`` + and a ``source`` URL pointing at the upstream feed (issue #830). + """ # Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color. # caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses # Apple iCal namespace which Nextcloud doesn't recognize. + # + # cs:source / ical:calendar-color are requested to surface external + # subscriptions: Nextcloud exposes those as cs:subscribed collections + # carrying a cs:source href and an Apple-namespace color. propfind_body = """ - + + + """ + # Override the client-wide webcal-caching header to "Off" for this + # PROPFIND so subscriptions are returned as cs:subscribed collections + # (with cs:source) and can be detected and flagged read-only. With the + # header "On" they would masquerade as regular calendars, hiding the + # source URL. Event reads keep the client-wide "On" so they stay + # queryable (see __init__). + # Pass the request XML via ``body``, not ``props``: caldav's ``props`` + # expects a list of property *names* and would build its own body + # (discarding this custom CalendarServer/Apple-namespace markup). response = await self._dav_client.propfind( self._calendar_home_url, - props=propfind_body, # type: ignore[arg-type] # props accepts XML body string + body=propfind_body, depth=1, + headers={"X-NC-CalDAV-Webcal-Caching": "Off"}, ) result = [] @@ -211,57 +241,79 @@ class CalendarClient: "d": "DAV:", "cs": "http://calendarserver.org/ns/", "c": "urn:ietf:params:xml:ns:caldav", + "ical": "http://apple.com/ns/ical/", } for response_elem in tree.findall(".//d:response", ns): - # Check if this is a calendar (has resourcetype/calendar) + # A response is a calendar if it is a regular calendar collection + # (c:calendar) or an external subscription (cs:subscribed). resourcetype = response_elem.find(".//d:resourcetype", ns) - if ( - resourcetype is not None - and resourcetype.find(".//c:calendar", ns) is not None - ): - href = response_elem.find("./d:href", ns) - if href is not None and href.text: - calendar_url = href.text - # Extract calendar name from URL - calendar_name = calendar_url.rstrip("/").split("/")[-1] + if resourcetype is None: + continue + is_calendar = resourcetype.find(".//c:calendar", ns) is not None + is_subscribed = resourcetype.find(".//cs:subscribed", ns) is not None + if not (is_calendar or is_subscribed): + continue - # Skip if this is the calendar home itself - if calendar_url.rstrip("/") == self._calendar_home_url.rstrip("/"): - continue + href = response_elem.find("./d:href", ns) + if href is None or not href.text: + continue - display_name_elem = response_elem.find(".//d:displayname", ns) - display_name = ( - display_name_elem.text - if display_name_elem is not None and display_name_elem.text - else calendar_name - ) + calendar_url = href.text + # Extract calendar name from URL + calendar_name = calendar_url.rstrip("/").split("/")[-1] - description_elem = response_elem.find( - ".//c:calendar-description", ns - ) - description = ( - description_elem.text - if description_elem is not None and description_elem.text - else "" - ) + # Skip if this is the calendar home itself + if calendar_url.rstrip("/") == self._calendar_home_url.rstrip("/"): + continue - color_elem = response_elem.find(".//cs:calendar-color", ns) - color = ( - color_elem.text - if color_elem is not None and color_elem.text - else "#1976D2" - ) + display_name_elem = response_elem.find(".//d:displayname", ns) + display_name = ( + display_name_elem.text + if display_name_elem is not None and display_name_elem.text + else calendar_name + ) - result.append( - { - "name": calendar_name, - "display_name": display_name, - "description": description, - "color": color, - "href": calendar_url, - } - ) + description_elem = response_elem.find(".//c:calendar-description", ns) + description = ( + description_elem.text + if description_elem is not None and description_elem.text + else "" + ) + + # Regular calendars expose cs:calendar-color; subscriptions store + # their color under the Apple iCal namespace. + color_elem = response_elem.find(".//cs:calendar-color", ns) + if color_elem is None or not color_elem.text: + color_elem = response_elem.find(".//ical:calendar-color", ns) + color = ( + color_elem.text + if color_elem is not None and color_elem.text + else "#1976D2" + ) + + # External subscriptions carry a cs:source href pointing at the + # upstream feed and are read-only. + source = None + source_elem = response_elem.find(".//cs:source", ns) + if source_elem is not None: + source_href = source_elem.find("./d:href", ns) + if source_href is not None and source_href.text: + source = source_href.text + elif source_elem.text and source_elem.text.strip(): + source = source_elem.text.strip() + + result.append( + { + "name": calendar_name, + "display_name": display_name, + "description": description, + "color": color, + "href": calendar_url, + "read_only": is_subscribed, + "source": source, + } + ) logger.debug("Found %s calendars", len(result)) return result diff --git a/nextcloud_mcp_server/models/calendar.py b/nextcloud_mcp_server/models/calendar.py index 1e903201..95e2ede4 100644 --- a/nextcloud_mcp_server/models/calendar.py +++ b/nextcloud_mcp_server/models/calendar.py @@ -18,6 +18,14 @@ class Calendar(BaseModel): timezone: Optional[str] = Field(None, description="Calendar timezone") enabled: bool = Field(default=True, description="Whether calendar is enabled") ctag: Optional[str] = Field(None, description="Calendar tag for synchronization") + read_only: bool = Field( + default=False, + description="Whether the calendar is read-only (e.g. an external subscription)", + ) + source: Optional[str] = Field( + None, + description="Source URL of an external/subscribed read-only calendar", + ) class CalendarEventSummary(BaseModel): diff --git a/tests/client/calendar/test_calendar_operations.py b/tests/client/calendar/test_calendar_operations.py index 22419c2c..cb0d4697 100644 --- a/tests/client/calendar/test_calendar_operations.py +++ b/tests/client/calendar/test_calendar_operations.py @@ -89,8 +89,18 @@ async def test_list_calendars(nc_client: NextcloudClient): # Optional fields assert "description" in calendar assert "color" in calendar + # External subscription metadata (issue #830): always present, with + # read_only=True / a source URL for subscribed calendars. + assert "read_only" in calendar + assert isinstance(calendar["read_only"], bool) + assert "source" in calendar - logger.info("Calendar: %s - %s", calendar["name"], calendar["display_name"]) + logger.info( + "Calendar: %s - %s (read_only=%s)", + calendar["name"], + calendar["display_name"], + calendar["read_only"], + ) async def test_create_and_delete_event( diff --git a/tests/unit/client/test_calendar.py b/tests/unit/client/test_calendar.py index bb7b15ce..43983b41 100644 --- a/tests/unit/client/test_calendar.py +++ b/tests/unit/client/test_calendar.py @@ -142,3 +142,138 @@ def test_auth_username_defaults_to_username(mocker): CalendarClient("https://cloud.example.org", "alice", password="app-pw") assert mock_dav_client.call_args.kwargs["username"] == "alice" + + +def test_webcal_caching_header_enabled_on_client(mocker): + """The client is constructed with the webcal-caching header turned on. + + This is what makes Nextcloud expose external subscriptions as queryable + CachedSubscription calendars, so their events are readable through the + normal event/search tools (issue #830). + """ + mock_dav_client = mocker.patch( + "nextcloud_mcp_server.client.calendar.AsyncDAVClient" + ) + + from nextcloud_mcp_server.client.calendar import CalendarClient + + CalendarClient("https://cloud.example.org", "alice", password="app-pw") + + headers = mock_dav_client.call_args.kwargs["headers"] + assert headers["X-NC-CalDAV-Webcal-Caching"] == "On" + + +# --- list_calendars: regular + external subscription parsing (issue #830) --- + +# A multistatus body with the calendar home, one regular calendar, and one +# external subscription (cs:subscribed) carrying a cs:source href and an +# Apple-namespace color. +_LIST_CALENDARS_MULTISTATUS = """ + + + /remote.php/dav/calendars/alice/ + + + HTTP/1.1 200 OK + + + + /remote.php/dav/calendars/alice/personal/ + + + Personal + + My personal calendar + #FF0000 + + HTTP/1.1 200 OK + + + + /remote.php/dav/calendars/alice/holidays/ + + + Public Holidays + + #00FF00 + https://example.com/holidays.ics + + HTTP/1.1 200 OK + + +""" + + +def _calendar_client_with_propfind(mocker, raw_xml: str): + """Build a CalendarClient whose DAV client returns ``raw_xml`` from PROPFIND.""" + mock_dav_client = mocker.patch( + "nextcloud_mcp_server.client.calendar.AsyncDAVClient" + ) + instance = mock_dav_client.return_value + instance.propfind = mocker.AsyncMock(return_value=mocker.Mock(raw=raw_xml)) + + from nextcloud_mcp_server.client.calendar import CalendarClient + + client = CalendarClient("https://cloud.example.org", "alice", password="app-pw") + return client, instance + + +async def test_list_calendars_includes_external_subscription(mocker): + """External subscriptions are returned alongside regular calendars and are + flagged read-only with their source feed URL (issue #830). + """ + client, _ = _calendar_client_with_propfind(mocker, _LIST_CALENDARS_MULTISTATUS) + + calendars = await client.list_calendars() + + by_name = {cal["name"]: cal for cal in calendars} + # The calendar home (plain collection) is not reported. + assert set(by_name) == {"personal", "holidays"} + + personal = by_name["personal"] + assert personal["display_name"] == "Personal" + assert personal["description"] == "My personal calendar" + assert personal["color"] == "#FF0000" + assert personal["read_only"] is False + assert personal["source"] is None + + holidays = by_name["holidays"] + assert holidays["display_name"] == "Public Holidays" + assert holidays["read_only"] is True + assert holidays["source"] == "https://example.com/holidays.ics" + # Subscriptions store their color under the Apple iCal namespace. + assert holidays["color"] == "#00FF00" + + +async def test_list_calendars_disables_webcal_caching_for_propfind(mocker): + """The listing PROPFIND overrides the client-wide header to "Off" so + subscriptions surface as cs:subscribed (with a source) rather than as + opaque regular calendars. + """ + client, instance = _calendar_client_with_propfind( + mocker, _LIST_CALENDARS_MULTISTATUS + ) + + await client.list_calendars() + + kwargs = instance.propfind.call_args.kwargs + assert kwargs["headers"]["X-NC-CalDAV-Webcal-Caching"] == "Off" + # The custom property XML must travel as ``body`` — caldav's ``props=`` + # expects a list of property names and would discard a raw XML string, + # sending an empty that returns neither resourcetype nor cs:source. + assert "cs:source" in kwargs["body"] + assert "props" not in kwargs + + +async def test_list_calendars_model_round_trip(mocker): + """The dicts returned by list_calendars validate against the Calendar model, + mirroring the server's ``Calendar(**cal_data)`` mapping. + """ + client, _ = _calendar_client_with_propfind(mocker, _LIST_CALENDARS_MULTISTATUS) + + from nextcloud_mcp_server.models.calendar import Calendar + + calendars = [Calendar(**cal) for cal in await client.list_calendars()] + holidays = next(c for c in calendars if c.name == "holidays") + assert holidays.read_only is True + assert holidays.source == "https://example.com/holidays.ics"