Skip to content

Commit e4d2ea2

Browse files
committed
fix(client): resolve DAV paths via principal discovery
- discover and cache DAV principal ids for WebDAV and CardDAV paths - resolve CalDAV calendar homes through async-safe principal lookup - cover divergent principal, fallback, retry, caching, and encoded href cases
1 parent 25b8986 commit e4d2ea2

9 files changed

Lines changed: 691 additions & 36 deletions

File tree

nextcloud_mcp_server/client/__init__.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,15 @@ def __init__(
107107
password: str | None = None,
108108
token: str | None = None,
109109
):
110-
# ``username`` is the Nextcloud UID — it drives DAV/API path
111-
# construction (e.g. ``/remote.php/dav/files/<uid>/``). ``auth_username``
112-
# is the credential identity Nextcloud authenticates the app password
113-
# against (the loginName), which differs from the UID for
114-
# OIDC-provisioned users. Defaults to ``username`` so single-user and
115-
# OAuth modes (where UID == loginName) are unchanged. Callers pass the
116-
# matching ``auth=BasicAuth(auth_username, ...)`` for the httpx leg;
117-
# ``auth_username`` is threaded to the CalDAV client, which builds its
118-
# own auth object from the raw credential.
110+
# ``username`` is the Nextcloud UID and DAV path fallback. Discovery can
111+
# replace that fallback with the canonical principal id when Nextcloud
112+
# exposes a different DAV identity. ``auth_username`` is the credential
113+
# identity Nextcloud authenticates the app password against (the
114+
# loginName), which differs from the UID for OIDC-provisioned users.
115+
# Defaults to ``username`` so single-user and OAuth modes are unchanged.
116+
# Callers pass the matching ``auth=BasicAuth(auth_username, ...)`` for
117+
# the httpx leg; ``auth_username`` is threaded to the CalDAV client,
118+
# which builds its own auth object from the raw credential.
119119
self.username = username
120120
auth_username = auth_username or username
121121
self._client = AsyncClient(
@@ -345,7 +345,7 @@ async def find_files_by_tag(
345345

346346
def _get_webdav_base_path(self) -> str:
347347
"""Helper to get the base WebDAV path for the authenticated user."""
348-
return f"/remote.php/dav/files/{self.username}"
348+
return self.webdav._get_webdav_base_path()
349349

350350
async def __aenter__(self):
351351
"""Async context manager entry."""

nextcloud_mcp_server/client/base.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
import logging
44
import time
5+
import xml.etree.ElementTree as ET
56
from abc import ABC
67
from functools import wraps
8+
from urllib.parse import unquote
79

810
import anyio
9-
from httpx import AsyncClient, HTTPStatusError, RequestError, codes
11+
from httpx import AsyncClient, HTTPError, HTTPStatusError, RequestError, codes
1012

1113
from nextcloud_mcp_server.observability.metrics import (
1214
record_nextcloud_api_call,
@@ -104,10 +106,66 @@ def __init__(self, http_client: AsyncClient, username: str):
104106
"""
105107
self._client = http_client
106108
self.username = username
109+
self._principal_id: str | None = None
110+
self._principal_discovered = False
107111

108112
def _get_webdav_base_path(self) -> str:
109113
"""Helper to get the base WebDAV path for the authenticated user."""
110-
return f"/remote.php/dav/files/{self.username}"
114+
return f"/remote.php/dav/files/{self._principal_or_username()}"
115+
116+
async def _ensure_principal_id(self) -> None:
117+
"""Discover the canonical DAV principal id via current-user-principal."""
118+
if getattr(self, "_principal_discovered", False):
119+
return
120+
121+
body = (
122+
'<?xml version="1.0" encoding="utf-8"?>'
123+
'<d:propfind xmlns:d="DAV:"><d:prop>'
124+
"<d:current-user-principal/>"
125+
"</d:prop></d:propfind>"
126+
)
127+
128+
try:
129+
response = await self._make_request(
130+
"PROPFIND",
131+
"/remote.php/dav/",
132+
content=body,
133+
headers={"Depth": "0", "Content-Type": "application/xml"},
134+
)
135+
root = ET.fromstring(response.content)
136+
href = None
137+
for element in root.iter():
138+
if element.tag.split("}")[-1] != "current-user-principal":
139+
continue
140+
for child in element.iter():
141+
if child.tag.split("}")[-1] == "href" and child.text:
142+
href = child.text.strip()
143+
break
144+
if href:
145+
break
146+
147+
if not href:
148+
logger.warning(
149+
"DAV principal discovery returned no href; using username path"
150+
)
151+
return
152+
153+
principal_id = unquote(href.rstrip("/").split("/")[-1])
154+
if not principal_id:
155+
logger.warning(
156+
"DAV principal discovery returned an empty principal id; "
157+
"using username path"
158+
)
159+
return
160+
161+
self._principal_id = principal_id
162+
self._principal_discovered = True
163+
except (HTTPError, ET.ParseError, ValueError) as e:
164+
logger.warning("DAV principal discovery failed; using username path: %s", e)
165+
166+
def _principal_or_username(self) -> str:
167+
"""Return the discovered DAV principal id, falling back to username."""
168+
return getattr(self, "_principal_id", None) or self.username
111169

112170
@staticmethod
113171
def _resolve_url(url: str) -> str:

nextcloud_mcp_server/client/calendar.py

Lines changed: 104 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
import logging
66
import uuid
77
from typing import Any
8+
from urllib.parse import unquote
89
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
910

1011
import anyio
12+
import httpx
1113
import recurring_ical_events
1214
from caldav.aio import AsyncCalendar, AsyncDAVClient, AsyncEvent
1315
from caldav.elements import cdav, dav
@@ -56,7 +58,7 @@ def __init__(
5658
5759
Args:
5860
base_url: Nextcloud base URL
59-
username: Nextcloud username (UID) used for DAV path construction
61+
username: Nextcloud username (UID) used as the DAV path fallback
6062
auth_username: Credential identity (loginName) the app password
6163
authenticates against; defaults to ``username``. Differs from
6264
the UID for OIDC-provisioned users.
@@ -68,10 +70,11 @@ def __init__(
6870
"""
6971
self.username = username
7072
self.base_url = base_url
71-
# The UID (``username``) drives DAV path construction; the loginName
72-
# (``auth_username``) is the credential the app password authenticates
73-
# against. They differ for OIDC-provisioned users. Defaults to the UID
74-
# so existing single-user / OAuth callers are unchanged.
73+
# The UID (``username``) is the DAV path fallback until principal
74+
# discovery succeeds; the loginName (``auth_username``) is the
75+
# credential the app password authenticates against. They differ for
76+
# OIDC-provisioned users. Defaults to the UID so existing single-user /
77+
# OAuth callers are unchanged.
7578
auth_username = auth_username or username
7679

7780
auth_kwargs: dict[str, Any] = {}
@@ -97,6 +100,85 @@ def __init__(
97100
**auth_kwargs,
98101
)
99102
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
103+
self._principal_resolved = False
104+
105+
def _calendar_home_url_from_home_set(self, home_set: Any) -> str | None:
106+
"""Normalize a caldav CalendarSet or URL into an absolute home URL."""
107+
if home_set is None:
108+
return None
109+
110+
home_url = getattr(home_set, "url", home_set)
111+
if home_url is None:
112+
return None
113+
114+
home_url = str(home_url)
115+
if not home_url:
116+
return None
117+
if home_url.startswith("/"):
118+
home_url = f"{self.base_url}{home_url}"
119+
if not home_url.endswith("/"):
120+
home_url = f"{home_url}/"
121+
return home_url
122+
123+
async def _calendar_home_url_from_principal(self, principal: Any) -> str | None:
124+
"""Resolve calendar-home-set without using caldav's async-unsafe property."""
125+
get_property = getattr(principal, "get_property", None)
126+
if get_property is not None:
127+
try:
128+
home_set = await _maybe_await(get_property(cdav.CalendarHomeSet()))
129+
calendar_home_url = self._calendar_home_url_from_home_set(home_set)
130+
if calendar_home_url:
131+
return calendar_home_url
132+
except (caldav_error.DAVError, AttributeError, TypeError, ValueError) as e:
133+
logger.warning(
134+
"CalDAV calendar-home-set discovery failed; deriving from "
135+
"principal URL: %s",
136+
e,
137+
)
138+
139+
try:
140+
home_set = getattr(principal, "calendar_home_set", None)
141+
home_set = await _maybe_await(home_set)
142+
return self._calendar_home_url_from_home_set(home_set)
143+
except (AttributeError, TypeError, ValueError) as e:
144+
logger.warning(
145+
"CalDAV calendar-home-set property unavailable; deriving from "
146+
"principal URL: %s",
147+
e,
148+
)
149+
return None
150+
151+
async def _ensure_calendar_home(self) -> None:
152+
"""Discover and cache the authenticated user's CalDAV calendar home."""
153+
if self._principal_resolved:
154+
return
155+
156+
try:
157+
get_principal = getattr(self._dav_client, "get_principal", None)
158+
if get_principal is None:
159+
principal = await _maybe_await(self._dav_client.principal())
160+
else:
161+
principal = await _maybe_await(get_principal())
162+
163+
calendar_home_url = await self._calendar_home_url_from_principal(principal)
164+
if calendar_home_url:
165+
self._calendar_home_url = calendar_home_url
166+
self._principal_resolved = True
167+
return
168+
169+
principal_url = getattr(principal, "url", None)
170+
if principal_url is None:
171+
raise ValueError("CalDAV principal discovery returned no URL")
172+
principal_id = unquote(str(principal_url).rstrip("/").split("/")[-1])
173+
if principal_id:
174+
self._calendar_home_url = (
175+
f"{self.base_url}/remote.php/dav/calendars/{principal_id}/"
176+
)
177+
self._principal_resolved = True
178+
except (caldav_error.DAVError, httpx.HTTPError, ValueError) as e:
179+
logger.warning(
180+
"CalDAV principal discovery failed; using username path: %s", e
181+
)
100182

101183
def _get_calendar_url(self, calendar_name: str) -> str:
102184
"""Get the full URL for a calendar."""
@@ -197,6 +279,7 @@ async def list_calendars(self) -> list[dict[str, Any]]:
197279
(webcal/ICS feeds). Subscriptions are reported with ``read_only=True``
198280
and a ``source`` URL pointing at the upstream feed (issue #830).
199281
"""
282+
await self._ensure_calendar_home()
200283
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
201284
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
202285
# Apple iCal namespace which Nextcloud doesn't recognize.
@@ -326,13 +409,12 @@ async def create_calendar(
326409
color: str = "#1976D2",
327410
) -> dict[str, Any]:
328411
"""Create a new calendar with retry on 429 errors."""
412+
await self._ensure_calendar_home()
329413
# Use custom MKCALENDAR XML instead of caldav library's make_calendar() due to:
330414
# 1. Missing CalendarServer namespace (cs:) in caldav's nsmap
331415
# 2. caldav's CalendarColor uses Apple iCal namespace, not cs:calendar-color
332416
# 3. make_calendar() doesn't support calendar-description or calendar-color params
333-
calendar_url = (
334-
f"{self.base_url}/remote.php/dav/calendars/{self.username}/{calendar_name}/"
335-
)
417+
calendar_url = self._get_calendar_url(calendar_name)
336418

337419
mkcalendar_body = f"""<?xml version="1.0" encoding="utf-8"?>
338420
<mkcalendar xmlns="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
@@ -372,10 +454,9 @@ async def create_calendar(
372454

373455
async def delete_calendar(self, calendar_name: str) -> dict[str, Any]:
374456
"""Delete a calendar."""
457+
await self._ensure_calendar_home()
375458
# Use absolute URL for deletion
376-
calendar_url = (
377-
f"{self.base_url}/remote.php/dav/calendars/{self.username}/{calendar_name}/"
378-
)
459+
calendar_url = self._get_calendar_url(calendar_name)
379460
await self._dav_client.delete(calendar_url)
380461

381462
logger.debug("Deleted calendar: %s", calendar_name)
@@ -391,6 +472,7 @@ async def get_calendar_events(
391472
limit: int = 50,
392473
) -> list[dict[str, Any]]:
393474
"""List events in a calendar within date range."""
475+
await self._ensure_calendar_home()
394476
calendar = self._get_calendar(calendar_name)
395477

396478
if start_datetime or end_datetime:
@@ -531,6 +613,7 @@ async def create_event(
531613
self, calendar_name: str, event_data: dict[str, Any]
532614
) -> dict[str, Any]:
533615
"""Create a new calendar event."""
616+
await self._ensure_calendar_home()
534617
calendar = self._get_calendar(calendar_name)
535618

536619
event_uid = str(uuid.uuid4())
@@ -556,6 +639,7 @@ async def update_event(
556639
etag: str = "",
557640
) -> dict[str, Any]:
558641
"""Update an existing calendar event."""
642+
await self._ensure_calendar_home()
559643
calendar = self._get_calendar(calendar_name)
560644

561645
# Find the event by UID using caldav library
@@ -580,6 +664,7 @@ async def update_event(
580664

581665
async def delete_event(self, calendar_name: str, event_uid: str) -> dict[str, Any]:
582666
"""Delete a calendar event."""
667+
await self._ensure_calendar_home()
583668
calendar = self._get_calendar(calendar_name)
584669

585670
try:
@@ -597,6 +682,7 @@ async def get_event(
597682
self, calendar_name: str, event_uid: str
598683
) -> tuple[dict[str, Any], str]:
599684
"""Get detailed information about a specific event."""
685+
await self._ensure_calendar_home()
600686
calendar = self._get_calendar(calendar_name)
601687

602688
event = await self._async_object_by_uid(
@@ -621,6 +707,7 @@ async def search_events_across_calendars(
621707
filters: dict[str, Any] | None = None,
622708
) -> list[dict[str, Any]]:
623709
"""Search events across all calendars with advanced filtering."""
710+
await self._ensure_calendar_home()
624711
try:
625712
calendars = await self.list_calendars()
626713
all_events = []
@@ -661,6 +748,7 @@ async def list_todos(
661748
self, calendar_name: str, filters: dict[str, Any] | None = None
662749
) -> list[dict[str, Any]]:
663750
"""List todos/tasks in a calendar."""
751+
await self._ensure_calendar_home()
664752
calendar = self._get_calendar(calendar_name)
665753

666754
# Get all todos including completed ones (filtering is done client-side)
@@ -690,6 +778,7 @@ async def create_todo(
690778
self, calendar_name: str, todo_data: dict[str, Any]
691779
) -> dict[str, Any]:
692780
"""Create a new todo/task."""
781+
await self._ensure_calendar_home()
693782
calendar = self._get_calendar(calendar_name)
694783

695784
todo_uid = str(uuid.uuid4())
@@ -715,6 +804,7 @@ async def update_todo(
715804
etag: str = "",
716805
) -> dict[str, Any]:
717806
"""Update an existing todo/task."""
807+
await self._ensure_calendar_home()
718808
calendar = self._get_calendar(calendar_name)
719809

720810
try:
@@ -754,6 +844,7 @@ async def update_todo(
754844

755845
async def delete_todo(self, calendar_name: str, todo_uid: str) -> dict[str, Any]:
756846
"""Delete a todo/task."""
847+
await self._ensure_calendar_home()
757848
calendar = self._get_calendar(calendar_name)
758849

759850
try:
@@ -771,6 +862,7 @@ async def search_todos_across_calendars(
771862
self, filters: dict[str, Any] | None = None
772863
) -> list[dict[str, Any]]:
773864
"""Search todos across all calendars."""
865+
await self._ensure_calendar_home()
774866
try:
775867
calendars = await self.list_calendars()
776868
all_todos = []
@@ -1457,6 +1549,7 @@ async def bulk_update_events(
14571549
self, filter_criteria: dict[str, Any], update_data: dict[str, Any]
14581550
) -> dict[str, Any]:
14591551
"""Bulk update events matching filter criteria."""
1552+
await self._ensure_calendar_home()
14601553
try:
14611554
start_datetime = None
14621555
end_datetime = None

0 commit comments

Comments
 (0)