Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec8eab99f3 | |||
| da104c59ac | |||
| b3e55d444b | |||
| 1786e204ec | |||
| 0a599c5c03 | |||
| 66e32d4705 | |||
| 8603ed114e | |||
| 7e6ef90423 | |||
| c5f2c8369f | |||
| b79ac29a9d |
@@ -5,6 +5,18 @@ 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)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- use CalDAV time-range filter for calendar date range queries
|
||||||
|
|
||||||
## v0.63.1 (2026-02-03)
|
## v0.63.1 (2026-02-03)
|
||||||
|
|
||||||
### 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.41"
|
||||||
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,18 @@ 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.41 (2026-02-08)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- expand recurring events in date-range queries
|
||||||
|
|
||||||
|
## 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.41
|
||||||
appVersion: "0.63.1"
|
appVersion: "0.63.3"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
|
|||||||
@@ -255,18 +255,35 @@ class CalendarClient:
|
|||||||
"""List events in a calendar within date range."""
|
"""List events in a calendar within date range."""
|
||||||
calendar = self._get_calendar(calendar_name)
|
calendar = self._get_calendar(calendar_name)
|
||||||
|
|
||||||
# Get all events using caldav library (now with proper filter)
|
if start_datetime or end_datetime:
|
||||||
events = await calendar.events()
|
# Build CalDAV REPORT with time-range filter for server-side filtering
|
||||||
|
events = await self._search_events_by_date(
|
||||||
|
calendar, start_datetime, end_datetime
|
||||||
|
)
|
||||||
|
# Expand is only used when both bounds are provided
|
||||||
|
expanded = bool(start_datetime and end_datetime)
|
||||||
|
else:
|
||||||
|
# No date filter — fetch all 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
|
||||||
@@ -274,6 +291,57 @@ class CalendarClient:
|
|||||||
logger.debug(f"Found {len(result)} events")
|
logger.debug(f"Found {len(result)} events")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def _search_events_by_date(
|
||||||
|
self,
|
||||||
|
calendar: AsyncCalendar,
|
||||||
|
start_datetime: Optional[dt.datetime] = None,
|
||||||
|
end_datetime: Optional[dt.datetime] = None,
|
||||||
|
) -> list:
|
||||||
|
"""Execute a CalDAV REPORT with time-range filter."""
|
||||||
|
from caldav.async_collection import AsyncEvent
|
||||||
|
from caldav.elements import cdav, dav
|
||||||
|
from lxml import etree # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
# Ensure naive datetimes are treated as UTC
|
||||||
|
if start_datetime and start_datetime.tzinfo is None:
|
||||||
|
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
|
||||||
|
if end_datetime and end_datetime.tzinfo is None:
|
||||||
|
end_datetime = end_datetime.replace(tzinfo=dt.UTC)
|
||||||
|
|
||||||
|
# Build comp-filter with time-range (mirrors sync Calendar.build_search_xml_query)
|
||||||
|
inner_comp_filter = cdav.CompFilter(name="VEVENT")
|
||||||
|
inner_comp_filter += cdav.TimeRange(start_datetime, end_datetime)
|
||||||
|
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
|
||||||
|
filter_element = cdav.Filter() + outer_comp_filter
|
||||||
|
|
||||||
|
# When both bounds are provided, request server-side expansion of
|
||||||
|
# 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(
|
||||||
|
query.xmlelement(), encoding="utf-8", xml_declaration=True
|
||||||
|
)
|
||||||
|
assert calendar.client is not None
|
||||||
|
response = await calendar.client.report(str(calendar.url), body, depth=1)
|
||||||
|
|
||||||
|
# Parse response (same pattern as AsyncCalendar.search)
|
||||||
|
objects = []
|
||||||
|
response_data = response.expand_simple_props([cdav.CalendarData()])
|
||||||
|
for href, props in response_data.items():
|
||||||
|
if href == str(calendar.url):
|
||||||
|
continue
|
||||||
|
cal_data = props.get(cdav.CalendarData.tag)
|
||||||
|
if cal_data:
|
||||||
|
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
|
||||||
|
objects.append(obj)
|
||||||
|
|
||||||
|
return objects
|
||||||
|
|
||||||
async def create_event(
|
async def create_event(
|
||||||
self, calendar_name: str, event_data: Dict[str, Any]
|
self, calendar_name: str, event_data: Dict[str, Any]
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -583,7 +651,7 @@ class CalendarClient:
|
|||||||
# Add categories
|
# Add categories
|
||||||
categories = event_data.get("categories", "")
|
categories = event_data.get("categories", "")
|
||||||
if categories:
|
if categories:
|
||||||
event.add("categories", categories.split(","))
|
event.add("categories", [c.strip() for c in categories.split(",")])
|
||||||
|
|
||||||
# Add priority and status
|
# Add priority and status
|
||||||
priority = event_data.get("priority", 5)
|
priority = event_data.get("priority", 5)
|
||||||
@@ -633,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:
|
||||||
@@ -727,6 +812,50 @@ class CalendarClient:
|
|||||||
if "url" in event_data:
|
if "url" in event_data:
|
||||||
component["URL"] = event_data["url"]
|
component["URL"] = event_data["url"]
|
||||||
|
|
||||||
|
# Handle categories
|
||||||
|
if "categories" in event_data:
|
||||||
|
categories_str = event_data["categories"]
|
||||||
|
if categories_str:
|
||||||
|
component["CATEGORIES"] = [
|
||||||
|
c.strip() for c in categories_str.split(",")
|
||||||
|
]
|
||||||
|
elif "CATEGORIES" in component:
|
||||||
|
del component["CATEGORIES"]
|
||||||
|
|
||||||
|
# Handle recurrence rule
|
||||||
|
if "recurrence_rule" in event_data:
|
||||||
|
rrule_str = event_data["recurrence_rule"]
|
||||||
|
if rrule_str:
|
||||||
|
component["RRULE"] = vRecur.from_ical(rrule_str)
|
||||||
|
elif "RRULE" in component:
|
||||||
|
del component["RRULE"]
|
||||||
|
|
||||||
|
# Handle attendees
|
||||||
|
if "attendees" in event_data:
|
||||||
|
attendees_str = event_data["attendees"]
|
||||||
|
# Remove all existing attendees first
|
||||||
|
while "ATTENDEE" in component:
|
||||||
|
del component["ATTENDEE"]
|
||||||
|
if attendees_str:
|
||||||
|
for email in attendees_str.split(","):
|
||||||
|
if email.strip():
|
||||||
|
component.add("attendee", f"mailto:{email.strip()}")
|
||||||
|
|
||||||
|
# Handle reminder (VALARM)
|
||||||
|
if "reminder_minutes" in event_data:
|
||||||
|
component.subcomponents = [
|
||||||
|
sub
|
||||||
|
for sub in component.subcomponents
|
||||||
|
if sub.name != "VALARM"
|
||||||
|
]
|
||||||
|
minutes = event_data["reminder_minutes"]
|
||||||
|
if minutes > 0:
|
||||||
|
alarm = Alarm()
|
||||||
|
alarm.add("action", "DISPLAY")
|
||||||
|
alarm.add("description", "Event reminder")
|
||||||
|
alarm.add("trigger", dt.timedelta(minutes=-minutes))
|
||||||
|
component.add_component(alarm)
|
||||||
|
|
||||||
# Handle dates
|
# Handle dates
|
||||||
if "start_datetime" in event_data:
|
if "start_datetime" in event_data:
|
||||||
start_str = event_data["start_datetime"]
|
start_str = event_data["start_datetime"]
|
||||||
@@ -960,7 +1089,9 @@ class CalendarClient:
|
|||||||
if "categories" in todo_data:
|
if "categories" in todo_data:
|
||||||
categories_str = todo_data["categories"]
|
categories_str = todo_data["categories"]
|
||||||
if categories_str:
|
if categories_str:
|
||||||
component["CATEGORIES"] = categories_str.split(",")
|
component["CATEGORIES"] = [
|
||||||
|
c.strip() for c in categories_str.split(",")
|
||||||
|
]
|
||||||
logger.debug(f"Set CATEGORIES to {categories_str}")
|
logger.debug(f"Set CATEGORIES to {categories_str}")
|
||||||
|
|
||||||
# Update timestamps
|
# Update timestamps
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.63.1"
|
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"}
|
||||||
|
|||||||
@@ -273,6 +273,86 @@ async def test_update_event(nc_client: NextcloudClient, temporary_event: dict):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_event_extended_fields(
|
||||||
|
nc_client: NextcloudClient, temporary_calendar: str
|
||||||
|
):
|
||||||
|
"""Test updating categories, recurrence_rule, attendees, and reminder_minutes."""
|
||||||
|
calendar_name = temporary_calendar
|
||||||
|
|
||||||
|
tomorrow = datetime.now() + timedelta(days=1)
|
||||||
|
event_data = {
|
||||||
|
"title": "Extended Fields Update Test",
|
||||||
|
"start_datetime": tomorrow.strftime("%Y-%m-%dT10:00:00"),
|
||||||
|
"end_datetime": tomorrow.strftime("%Y-%m-%dT11:00:00"),
|
||||||
|
"description": "Base event for extended-field update test",
|
||||||
|
}
|
||||||
|
|
||||||
|
event_uid = None
|
||||||
|
try:
|
||||||
|
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||||
|
event_uid = result["uid"]
|
||||||
|
logger.info(f"Created base event for extended fields test: {event_uid}")
|
||||||
|
|
||||||
|
# --- Phase 1: Set all four extended fields ---
|
||||||
|
updated_data = {
|
||||||
|
"categories": "work,meeting",
|
||||||
|
"recurrence_rule": "FREQ=WEEKLY;COUNT=4",
|
||||||
|
"attendees": "alice@example.com,bob@example.com",
|
||||||
|
"reminder_minutes": 15,
|
||||||
|
}
|
||||||
|
await nc_client.calendar.update_event(calendar_name, event_uid, updated_data)
|
||||||
|
|
||||||
|
retrieved, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||||
|
|
||||||
|
# Verify categories
|
||||||
|
assert "work" in retrieved.get("categories", "")
|
||||||
|
assert "meeting" in retrieved.get("categories", "")
|
||||||
|
|
||||||
|
# Verify recurrence rule
|
||||||
|
assert retrieved.get("recurring") is True
|
||||||
|
assert "WEEKLY" in retrieved.get("recurrence_rule", "")
|
||||||
|
|
||||||
|
# Verify attendees
|
||||||
|
attendees = retrieved.get("attendees", "")
|
||||||
|
assert "alice@example.com" in attendees
|
||||||
|
assert "bob@example.com" in attendees
|
||||||
|
|
||||||
|
logger.info("Phase 1 passed: all extended fields set correctly")
|
||||||
|
|
||||||
|
# --- Phase 2: Clear all four extended fields ---
|
||||||
|
cleared_data = {
|
||||||
|
"categories": "",
|
||||||
|
"recurrence_rule": "",
|
||||||
|
"attendees": "",
|
||||||
|
"reminder_minutes": 0,
|
||||||
|
}
|
||||||
|
await nc_client.calendar.update_event(calendar_name, event_uid, cleared_data)
|
||||||
|
|
||||||
|
cleared, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||||
|
|
||||||
|
# Verify categories cleared
|
||||||
|
assert not cleared.get("categories")
|
||||||
|
|
||||||
|
# Verify recurrence cleared
|
||||||
|
assert cleared.get("recurring") is not True
|
||||||
|
assert not cleared.get("recurrence_rule")
|
||||||
|
|
||||||
|
# Verify attendees cleared
|
||||||
|
assert not cleared.get("attendees")
|
||||||
|
|
||||||
|
logger.info("Phase 2 passed: all extended fields cleared correctly")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Extended fields update test failed: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if event_uid:
|
||||||
|
try:
|
||||||
|
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def test_create_event_with_attendees(
|
async def test_create_event_with_attendees(
|
||||||
nc_client: NextcloudClient, temporary_calendar: str
|
nc_client: NextcloudClient, temporary_calendar: str
|
||||||
):
|
):
|
||||||
@@ -380,6 +460,177 @@ async def test_event_with_url_and_categories(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_events_date_range_filtering(
|
||||||
|
nc_client: NextcloudClient, temporary_calendar: str
|
||||||
|
):
|
||||||
|
"""Test that date range filtering actually excludes events outside the range.
|
||||||
|
|
||||||
|
Reproduces GH-538: get_calendar_events() accepted date range parameters
|
||||||
|
but returned events from the entire calendar history, ignoring date filters.
|
||||||
|
"""
|
||||||
|
calendar_name = temporary_calendar
|
||||||
|
past_uid = None
|
||||||
|
future_uid = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create Event A: 30 days in the past
|
||||||
|
past_date = datetime.now() - timedelta(days=30)
|
||||||
|
past_event_data = {
|
||||||
|
"title": f"Past Event {uuid.uuid4().hex[:8]}",
|
||||||
|
"start_datetime": past_date.strftime("%Y-%m-%dT10:00:00"),
|
||||||
|
"end_datetime": past_date.strftime("%Y-%m-%dT11:00:00"),
|
||||||
|
"description": "Event in the past for date range test",
|
||||||
|
}
|
||||||
|
result_past = await nc_client.calendar.create_event(
|
||||||
|
calendar_name, past_event_data
|
||||||
|
)
|
||||||
|
past_uid = result_past["uid"]
|
||||||
|
logger.info(f"Created past event: {past_uid}")
|
||||||
|
|
||||||
|
# Create Event B: 1 day in the future
|
||||||
|
future_date = datetime.now() + timedelta(days=1)
|
||||||
|
future_event_data = {
|
||||||
|
"title": f"Future Event {uuid.uuid4().hex[:8]}",
|
||||||
|
"start_datetime": future_date.strftime("%Y-%m-%dT14:00:00"),
|
||||||
|
"end_datetime": future_date.strftime("%Y-%m-%dT15:00:00"),
|
||||||
|
"description": "Event in the future for date range test",
|
||||||
|
}
|
||||||
|
result_future = await nc_client.calendar.create_event(
|
||||||
|
calendar_name, future_event_data
|
||||||
|
)
|
||||||
|
future_uid = result_future["uid"]
|
||||||
|
logger.info(f"Created future event: {future_uid}")
|
||||||
|
|
||||||
|
# Query with date range: today → 7 days ahead
|
||||||
|
now = datetime.now()
|
||||||
|
week_ahead = now + timedelta(days=7)
|
||||||
|
|
||||||
|
events = await nc_client.calendar.get_calendar_events(
|
||||||
|
calendar_name=calendar_name,
|
||||||
|
start_datetime=now,
|
||||||
|
end_datetime=week_ahead,
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
event_uids = [e["uid"] for e in events]
|
||||||
|
|
||||||
|
# Future event (tomorrow) SHOULD be in results
|
||||||
|
assert future_uid in event_uids, (
|
||||||
|
f"Future event {future_uid} should be in date-filtered results"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Past event (30 days ago) should NOT be in results
|
||||||
|
assert past_uid not in event_uids, (
|
||||||
|
f"Past event {past_uid} should be excluded by date range filter "
|
||||||
|
f"(GH-538: date range was being ignored)"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Date range filtering works: {len(events)} events returned, "
|
||||||
|
f"past event correctly excluded"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup both events
|
||||||
|
for uid in [past_uid, future_uid]:
|
||||||
|
if uid:
|
||||||
|
try:
|
||||||
|
await nc_client.calendar.delete_event(calendar_name, uid)
|
||||||
|
except Exception as 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,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Integration tests for Calendar VEVENT update MCP tools - extended fields."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mcp import ClientSession
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_update_event_extended_fields(
|
||||||
|
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
|
||||||
|
):
|
||||||
|
"""Test updating categories, recurrence_rule, attendees, and reminder_minutes via MCP."""
|
||||||
|
|
||||||
|
calendar_name = temporary_calendar
|
||||||
|
event_uid = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Create a base event via MCP
|
||||||
|
tomorrow = datetime.now() + timedelta(days=1)
|
||||||
|
create_result = await nc_mcp_client.call_tool(
|
||||||
|
"nc_calendar_create_event",
|
||||||
|
{
|
||||||
|
"calendar_name": calendar_name,
|
||||||
|
"title": "Extended Fields MCP Test",
|
||||||
|
"start_datetime": tomorrow.strftime("%Y-%m-%dT14:00:00"),
|
||||||
|
"end_datetime": tomorrow.strftime("%Y-%m-%dT15:00:00"),
|
||||||
|
"description": "Base event for MCP extended-field update test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert create_result.isError is False, (
|
||||||
|
f"MCP event creation failed: {create_result.content}"
|
||||||
|
)
|
||||||
|
|
||||||
|
result_data = json.loads(create_result.content[0].text)
|
||||||
|
event_uid = result_data["uid"]
|
||||||
|
logger.info(f"Created base event via MCP: {event_uid}")
|
||||||
|
|
||||||
|
# 2. Update with all four extended fields via MCP
|
||||||
|
update_result = await nc_mcp_client.call_tool(
|
||||||
|
"nc_calendar_update_event",
|
||||||
|
{
|
||||||
|
"calendar_name": calendar_name,
|
||||||
|
"event_uid": event_uid,
|
||||||
|
"categories": "work,meeting",
|
||||||
|
"recurrence_rule": "FREQ=WEEKLY;COUNT=4",
|
||||||
|
"attendees": "alice@example.com,bob@example.com",
|
||||||
|
"reminder_minutes": 15,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert update_result.isError is False, (
|
||||||
|
f"MCP event update failed: {update_result.content}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Verify via direct client
|
||||||
|
event, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||||
|
|
||||||
|
# Categories
|
||||||
|
assert "work" in event.get("categories", ""), (
|
||||||
|
f"Expected 'work' in categories, got: {event.get('categories')}"
|
||||||
|
)
|
||||||
|
assert "meeting" in event.get("categories", ""), (
|
||||||
|
f"Expected 'meeting' in categories, got: {event.get('categories')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recurrence
|
||||||
|
assert event.get("recurring") is True, "Expected event to be recurring"
|
||||||
|
assert "WEEKLY" in event.get("recurrence_rule", ""), (
|
||||||
|
f"Expected WEEKLY in rrule, got: {event.get('recurrence_rule')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attendees
|
||||||
|
attendees = event.get("attendees", "")
|
||||||
|
assert "alice@example.com" in attendees, (
|
||||||
|
f"Expected alice in attendees, got: {attendees}"
|
||||||
|
)
|
||||||
|
assert "bob@example.com" in attendees, (
|
||||||
|
f"Expected bob in attendees, got: {attendees}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("MCP extended fields update verified successfully")
|
||||||
|
|
||||||
|
# 4. Clear all four fields via MCP
|
||||||
|
clear_result = await nc_mcp_client.call_tool(
|
||||||
|
"nc_calendar_update_event",
|
||||||
|
{
|
||||||
|
"calendar_name": calendar_name,
|
||||||
|
"event_uid": event_uid,
|
||||||
|
"categories": "",
|
||||||
|
"recurrence_rule": "",
|
||||||
|
"attendees": "",
|
||||||
|
"reminder_minutes": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert clear_result.isError is False, (
|
||||||
|
f"MCP event clear failed: {clear_result.content}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Verify fields cleared
|
||||||
|
cleared, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||||
|
assert not cleared.get("categories"), (
|
||||||
|
f"Expected categories cleared, got: {cleared.get('categories')}"
|
||||||
|
)
|
||||||
|
assert cleared.get("recurring") is not True, (
|
||||||
|
f"Expected recurring cleared, got: {cleared.get('recurring')}"
|
||||||
|
)
|
||||||
|
assert not cleared.get("attendees"), (
|
||||||
|
f"Expected attendees cleared, got: {cleared.get('attendees')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("MCP extended fields clear verified successfully")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if event_uid:
|
||||||
|
try:
|
||||||
|
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -1988,7 +1988,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.63.1"
|
version = "0.63.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user