Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec8eab99f3 | |||
| da104c59ac | |||
| b3e55d444b | |||
| 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.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.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
|
||||||
@@ -635,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)
|
||||||
@@ -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:
|
||||||
@@ -779,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"]
|
||||||
@@ -1012,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.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"}
|
||||||
|
|||||||
@@ -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
|
||||||
):
|
):
|
||||||
@@ -460,6 +540,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,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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.2"
|
version = "0.63.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user