Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1786e204ec | |||
| 0a599c5c03 | |||
| 66e32d4705 | |||
| 8603ed114e |
@@ -5,6 +5,12 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||||
|
|
||||||
|
## v0.63.3 (2026-02-08)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- expand recurring events in date-range queries
|
||||||
|
|
||||||
## v0.63.2 (2026-02-07)
|
## v0.63.2 (2026-02-07)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.57.39"
|
version = "0.57.40"
|
||||||
tag_format = "nextcloud-mcp-server-$version"
|
tag_format = "nextcloud-mcp-server-$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Configurable resource limits
|
- Configurable resource limits
|
||||||
- Grafana dashboard annotations
|
- Grafana dashboard annotations
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.40 (2026-02-07)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- use CalDAV time-range filter for calendar date range queries
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.57.39 (2026-02-07)
|
## nextcloud-mcp-server-0.57.39 (2026-02-07)
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.57.38 (2026-02-07)
|
## nextcloud-mcp-server-0.57.38 (2026-02-07)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.57.39
|
version: 0.57.40
|
||||||
appVersion: "0.63.2"
|
appVersion: "0.63.3"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
|
|||||||
@@ -260,19 +260,30 @@ class CalendarClient:
|
|||||||
events = await self._search_events_by_date(
|
events = await self._search_events_by_date(
|
||||||
calendar, start_datetime, end_datetime
|
calendar, start_datetime, end_datetime
|
||||||
)
|
)
|
||||||
|
# Expand is only used when both bounds are provided
|
||||||
|
expanded = bool(start_datetime and end_datetime)
|
||||||
else:
|
else:
|
||||||
# No date filter — fetch all events
|
# No date filter — fetch all events
|
||||||
events = await calendar.events()
|
events = await calendar.events()
|
||||||
|
expanded = False
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for event in events:
|
for event in events:
|
||||||
await event.load(only_if_unloaded=True)
|
await event.load(only_if_unloaded=True)
|
||||||
if event.data:
|
if event.data:
|
||||||
event_dict = self._parse_ical_event(event.data)
|
if expanded:
|
||||||
if event_dict:
|
# Server-side expansion: each response resource may contain
|
||||||
event_dict["href"] = str(event.url)
|
# multiple VEVENTs (one per recurrence occurrence)
|
||||||
event_dict["etag"] = ""
|
for event_dict in self._parse_all_ical_events(event.data):
|
||||||
result.append(event_dict)
|
event_dict["href"] = str(event.url)
|
||||||
|
event_dict["etag"] = ""
|
||||||
|
result.append(event_dict)
|
||||||
|
else:
|
||||||
|
event_dict = self._parse_ical_event(event.data)
|
||||||
|
if event_dict:
|
||||||
|
event_dict["href"] = str(event.url)
|
||||||
|
event_dict["etag"] = ""
|
||||||
|
result.append(event_dict)
|
||||||
|
|
||||||
if len(result) >= limit:
|
if len(result) >= limit:
|
||||||
break
|
break
|
||||||
@@ -303,9 +314,14 @@ class CalendarClient:
|
|||||||
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
|
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
|
||||||
filter_element = cdav.Filter() + outer_comp_filter
|
filter_element = cdav.Filter() + outer_comp_filter
|
||||||
|
|
||||||
query = (
|
# When both bounds are provided, request server-side expansion of
|
||||||
cdav.CalendarQuery() + [dav.Prop() + cdav.CalendarData()] + filter_element
|
# recurring events (RFC 4791 §9.6.5). Each occurrence is returned as
|
||||||
)
|
# a separate VEVENT with its own DTSTART, with RRULE stripped.
|
||||||
|
data = cdav.CalendarData()
|
||||||
|
if start_datetime and end_datetime:
|
||||||
|
data += cdav.Expand(start_datetime, end_datetime)
|
||||||
|
|
||||||
|
query = cdav.CalendarQuery() + [dav.Prop() + data] + filter_element
|
||||||
|
|
||||||
body = etree.tostring(
|
body = etree.tostring(
|
||||||
query.xmlelement(), encoding="utf-8", xml_declaration=True
|
query.xmlelement(), encoding="utf-8", xml_declaration=True
|
||||||
@@ -685,75 +701,92 @@ class CalendarClient:
|
|||||||
cal.add_component(event)
|
cal.add_component(event)
|
||||||
return cal.to_ical().decode("utf-8")
|
return cal.to_ical().decode("utf-8")
|
||||||
|
|
||||||
|
def _extract_vevent_data(self, component) -> Dict[str, Any]:
|
||||||
|
"""Extract event data from a single VEVENT component.
|
||||||
|
|
||||||
|
Shared helper used by both _parse_ical_event() and _parse_all_ical_events().
|
||||||
|
"""
|
||||||
|
event_data: Dict[str, Any] = {
|
||||||
|
"uid": str(component.get("uid", "")),
|
||||||
|
"title": str(component.get("summary", "")),
|
||||||
|
"description": str(component.get("description", "")),
|
||||||
|
"location": str(component.get("location", "")),
|
||||||
|
"status": str(component.get("status", "CONFIRMED")),
|
||||||
|
"priority": int(component.get("priority", 5)),
|
||||||
|
"privacy": str(component.get("class", "PUBLIC")),
|
||||||
|
"url": str(component.get("url", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle dates
|
||||||
|
dtstart = component.get("dtstart")
|
||||||
|
if dtstart:
|
||||||
|
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
||||||
|
dtstart.dt, dt.datetime
|
||||||
|
):
|
||||||
|
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||||
|
event_data["all_day"] = True
|
||||||
|
else:
|
||||||
|
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||||
|
event_data["all_day"] = False
|
||||||
|
|
||||||
|
dtend = component.get("dtend")
|
||||||
|
if dtend:
|
||||||
|
if isinstance(dtend.dt, dt.date) and not isinstance(dtend.dt, dt.datetime):
|
||||||
|
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||||
|
else:
|
||||||
|
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||||
|
|
||||||
|
# Handle categories
|
||||||
|
categories = component.get("categories")
|
||||||
|
if categories:
|
||||||
|
event_data["categories"] = self._extract_categories(categories)
|
||||||
|
|
||||||
|
# Handle recurrence
|
||||||
|
rrule = component.get("rrule")
|
||||||
|
if rrule:
|
||||||
|
event_data["recurring"] = True
|
||||||
|
event_data["recurrence_rule"] = str(rrule)
|
||||||
|
|
||||||
|
# Handle attendees
|
||||||
|
attendees = []
|
||||||
|
for attendee in component.get("attendee", []):
|
||||||
|
if isinstance(attendee, list):
|
||||||
|
attendees.extend(str(a).replace("mailto:", "") for a in attendee)
|
||||||
|
else:
|
||||||
|
attendees.append(str(attendee).replace("mailto:", ""))
|
||||||
|
if attendees:
|
||||||
|
event_data["attendees"] = ",".join(attendees)
|
||||||
|
|
||||||
|
return event_data
|
||||||
|
|
||||||
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]:
|
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Parse iCalendar text and extract event data."""
|
"""Parse iCalendar text and extract the first event."""
|
||||||
try:
|
try:
|
||||||
cal = Calendar.from_ical(ical_text)
|
cal = Calendar.from_ical(ical_text)
|
||||||
for component in cal.walk():
|
for component in cal.walk():
|
||||||
if component.name == "VEVENT":
|
if component.name == "VEVENT":
|
||||||
event_data = {
|
return self._extract_vevent_data(component)
|
||||||
"uid": str(component.get("uid", "")),
|
|
||||||
"title": str(component.get("summary", "")),
|
|
||||||
"description": str(component.get("description", "")),
|
|
||||||
"location": str(component.get("location", "")),
|
|
||||||
"status": str(component.get("status", "CONFIRMED")),
|
|
||||||
"priority": int(component.get("priority", 5)),
|
|
||||||
"privacy": str(component.get("class", "PUBLIC")),
|
|
||||||
"url": str(component.get("url", "")),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Handle dates
|
|
||||||
dtstart = component.get("dtstart")
|
|
||||||
if dtstart:
|
|
||||||
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
|
||||||
dtstart.dt, dt.datetime
|
|
||||||
):
|
|
||||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
|
||||||
event_data["all_day"] = True
|
|
||||||
else:
|
|
||||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
|
||||||
event_data["all_day"] = False
|
|
||||||
|
|
||||||
dtend = component.get("dtend")
|
|
||||||
if dtend:
|
|
||||||
if isinstance(dtend.dt, dt.date) and not isinstance(
|
|
||||||
dtend.dt, dt.datetime
|
|
||||||
):
|
|
||||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
|
||||||
else:
|
|
||||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
|
||||||
|
|
||||||
# Handle categories
|
|
||||||
categories = component.get("categories")
|
|
||||||
if categories:
|
|
||||||
event_data["categories"] = self._extract_categories(categories)
|
|
||||||
|
|
||||||
# Handle recurrence
|
|
||||||
rrule = component.get("rrule")
|
|
||||||
if rrule:
|
|
||||||
event_data["recurring"] = True
|
|
||||||
event_data["recurrence_rule"] = str(rrule)
|
|
||||||
|
|
||||||
# Handle attendees
|
|
||||||
attendees = []
|
|
||||||
for attendee in component.get("attendee", []):
|
|
||||||
if isinstance(attendee, list):
|
|
||||||
attendees.extend(
|
|
||||||
str(a).replace("mailto:", "") for a in attendee
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
attendees.append(str(attendee).replace("mailto:", ""))
|
|
||||||
if attendees:
|
|
||||||
event_data["attendees"] = ",".join(attendees)
|
|
||||||
|
|
||||||
return event_data
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing iCalendar event: {e}")
|
logger.error(f"Error parsing iCalendar event: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _parse_all_ical_events(self, ical_text: str) -> list[Dict[str, Any]]:
|
||||||
|
"""Parse iCalendar text and extract ALL event occurrences.
|
||||||
|
|
||||||
|
Used with server-side expansion where a single VCALENDAR contains
|
||||||
|
multiple VEVENT components (one per recurrence occurrence).
|
||||||
|
"""
|
||||||
|
results: list[Dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
cal = Calendar.from_ical(ical_text)
|
||||||
|
for component in cal.walk():
|
||||||
|
if component.name == "VEVENT":
|
||||||
|
results.append(self._extract_vevent_data(component))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing iCalendar events: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
def _merge_ical_properties(
|
def _merge_ical_properties(
|
||||||
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
|
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.63.2"
|
version = "0.63.3"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
|
|||||||
@@ -460,6 +460,97 @@ async def test_list_events_date_range_filtering(
|
|||||||
logger.warning(f"Cleanup failed for event {uid}: {e}")
|
logger.warning(f"Cleanup failed for event {uid}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recurring_event_date_range_expansion(
|
||||||
|
nc_client: NextcloudClient, temporary_calendar: str
|
||||||
|
):
|
||||||
|
"""Test that recurring events are expanded into individual occurrences.
|
||||||
|
|
||||||
|
When querying with a date range, a recurring event should return one
|
||||||
|
event dict per occurrence within the range, each with the correct
|
||||||
|
start_datetime for that occurrence (not the original master event date).
|
||||||
|
|
||||||
|
This is a follow-up to GH-538: the time-range filter correctly selected
|
||||||
|
recurring events, but returned the master event with its original DTSTART
|
||||||
|
instead of expanding occurrences.
|
||||||
|
"""
|
||||||
|
calendar_name = temporary_calendar
|
||||||
|
event_uid = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a daily recurring event starting 7 days ago
|
||||||
|
start = datetime.now() - timedelta(days=7)
|
||||||
|
event_data = {
|
||||||
|
"title": f"Daily Recurrence {uuid.uuid4().hex[:8]}",
|
||||||
|
"start_datetime": start.strftime("%Y-%m-%dT09:00:00"),
|
||||||
|
"end_datetime": start.strftime("%Y-%m-%dT10:00:00"),
|
||||||
|
"description": "Daily recurring event for expansion test",
|
||||||
|
"recurring": True,
|
||||||
|
"recurrence_rule": "FREQ=DAILY",
|
||||||
|
}
|
||||||
|
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||||
|
event_uid = result["uid"]
|
||||||
|
logger.info(f"Created daily recurring event: {event_uid}")
|
||||||
|
|
||||||
|
# Query with date range: today → 3 days ahead
|
||||||
|
query_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
query_end = query_start + timedelta(days=3)
|
||||||
|
|
||||||
|
events = await nc_client.calendar.get_calendar_events(
|
||||||
|
calendar_name=calendar_name,
|
||||||
|
start_datetime=query_start,
|
||||||
|
end_datetime=query_end,
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter to only our recurring event (calendar may have others)
|
||||||
|
our_events = [e for e in events if e["uid"] == event_uid]
|
||||||
|
|
||||||
|
# Should have multiple occurrences (one per day in the range)
|
||||||
|
assert len(our_events) >= 2, (
|
||||||
|
f"Expected multiple expanded occurrences, got {len(our_events)}. "
|
||||||
|
f"Expansion may not be working."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Each occurrence should have a different start_datetime
|
||||||
|
start_dates = [e["start_datetime"] for e in our_events]
|
||||||
|
assert len(set(start_dates)) == len(our_events), (
|
||||||
|
f"Each occurrence should have a unique start_datetime, got: {start_dates}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No start_datetime should fall outside the queried range
|
||||||
|
for e in our_events:
|
||||||
|
event_start = datetime.fromisoformat(e["start_datetime"])
|
||||||
|
# Remove timezone info for comparison if present
|
||||||
|
if event_start.tzinfo is not None:
|
||||||
|
event_start = event_start.replace(tzinfo=None)
|
||||||
|
assert event_start >= query_start - timedelta(hours=1), (
|
||||||
|
f"Occurrence {e['start_datetime']} is before query start {query_start}"
|
||||||
|
)
|
||||||
|
assert event_start < query_end + timedelta(hours=1), (
|
||||||
|
f"Occurrence {e['start_datetime']} is after query end {query_end}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expanded occurrences should NOT have recurrence rules
|
||||||
|
# (server strips RRULE when expanding)
|
||||||
|
for e in our_events:
|
||||||
|
assert not e.get("recurring"), (
|
||||||
|
"Expanded occurrence should not have recurring=True, "
|
||||||
|
"RRULE should be stripped by server-side expansion"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Recurring event expansion works: {len(our_events)} occurrences "
|
||||||
|
f"returned with unique start dates"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if event_uid:
|
||||||
|
try:
|
||||||
|
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Cleanup failed for recurring event {event_uid}: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def test_calendar_operations_error_handling(
|
async def test_calendar_operations_error_handling(
|
||||||
nc_client: NextcloudClient,
|
nc_client: NextcloudClient,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -1988,7 +1988,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.63.2"
|
version = "0.63.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user