Skip to content

Commit f8dc1f0

Browse files
authored
Merge pull request #137 from cbcoutinho/feature/claude-code
Feature/claude code
2 parents 464ff2c + 4cf5f2a commit f8dc1f0

File tree

21 files changed

+2160
-80
lines changed

21 files changed

+2160
-80
lines changed

.pre-commit-config.yaml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@ repos:
66
- id: commitizen-branch
77
stages:
88
- pre-push
9-
- repo: https://github.com/astral-sh/ruff-pre-commit
10-
rev: v0.12.5
9+
- repo: local
1110
hooks:
1211
- id: ruff-check
12+
name: ruff-check
13+
entry: uv run ruff check
14+
language: system
15+
types: [python]
1316
- id: ruff-format
17+
name: ruff-format
18+
entry: uv run ruff format
19+
language: system
20+
types: [python]

CLAUDE.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Development Commands
6+
7+
### Testing
8+
```bash
9+
# Run all tests
10+
uv run pytest
11+
12+
# Run integration tests only
13+
uv run pytest -m integration
14+
15+
# Run tests with coverage
16+
uv run pytest --cov
17+
18+
# Skip integration tests
19+
uv run pytest -m "not integration"
20+
```
21+
22+
### Code Quality
23+
```bash
24+
# Format and lint code
25+
uv run ruff check
26+
uv run ruff format
27+
28+
# Type checking
29+
# No explicit type checker configured - this is a Python project using ruff for linting
30+
```
31+
32+
### Running the Server
33+
```bash
34+
# Local development - load environment variables and run
35+
export $(grep -v '^#' .env | xargs)
36+
mcp run --transport sse nextcloud_mcp_server.app:mcp
37+
38+
# Docker development environment with Nextcloud instance
39+
docker-compose up
40+
41+
# After code changes, rebuild and restart only the MCP server container
42+
docker-compose up --build -d mcp
43+
44+
# Build Docker image
45+
docker build -t nextcloud-mcp-server .
46+
```
47+
48+
### Environment Setup
49+
```bash
50+
# Install dependencies
51+
uv sync
52+
53+
# Install development dependencies
54+
uv sync --group dev
55+
```
56+
57+
## Architecture Overview
58+
59+
This is a Python MCP (Model Context Protocol) server that provides LLM integration with Nextcloud. The architecture follows a layered pattern:
60+
61+
### Core Components
62+
63+
- **`nextcloud_mcp_server/app.py`** - Main MCP server entry point using FastMCP framework
64+
- **`nextcloud_mcp_server/client/`** - HTTP client implementations for different Nextcloud APIs
65+
- **`nextcloud_mcp_server/server/`** - MCP tool/resource definitions that expose client functionality
66+
- **`nextcloud_mcp_server/controllers/`** - Business logic controllers (e.g., notes search)
67+
68+
### Client Architecture
69+
70+
- **`NextcloudClient`** - Main orchestrating client that manages all app-specific clients
71+
- **`BaseNextcloudClient`** - Abstract base class providing common HTTP functionality and retry logic
72+
- **App-specific clients**: `NotesClient`, `CalendarClient`, `ContactsClient`, `TablesClient`, `WebDAVClient`
73+
74+
### Server Integration
75+
76+
Each Nextcloud app has a corresponding server module that:
77+
1. Defines MCP tools using `@mcp.tool()` decorators
78+
2. Defines MCP resources using `@mcp.resource()` decorators
79+
3. Uses the context pattern to access the `NextcloudClient` instance
80+
81+
### Supported Nextcloud Apps
82+
83+
- **Notes** - Full CRUD operations and search
84+
- **Calendar** - CalDAV integration with events, recurring events, attendees
85+
- **Contacts** - CardDAV integration with address book operations
86+
- **Tables** - Row-level operations on Nextcloud Tables
87+
- **WebDAV** - Complete file system access
88+
89+
### Key Patterns
90+
91+
1. **Environment-based configuration** - Uses `NextcloudClient.from_env()` to load credentials from environment variables
92+
2. **Async/await throughout** - All operations are async using httpx
93+
3. **Retry logic** - `@retry_on_429` decorator handles rate limiting
94+
4. **Context injection** - MCP context provides access to the authenticated client instance
95+
5. **Modular design** - Each Nextcloud app is isolated in its own client/server pair
96+
97+
### Testing Structure
98+
99+
- **Integration tests** in `tests/integration/` - Test real Nextcloud API interactions
100+
- **Fixtures** in `tests/conftest.py` - Shared test setup and utilities
101+
- Tests are marked with `@pytest.mark.integration` for selective running
102+
- **Important**: Integration tests run against live Docker containers. After making code changes to the MCP server, rebuild only the MCP container with `docker-compose up --build -d mcp` before running tests
103+
104+
#### Testing Best Practices
105+
- **Always restart MCP server** after code changes with `docker-compose up --build -d mcp`
106+
- **Use existing fixtures** from `tests/conftest.py` to avoid duplicate setup work:
107+
- `nc_mcp_client` - MCP client session for tool/resource testing
108+
- `nc_client` - Direct NextcloudClient for setup/cleanup operations
109+
- `temporary_note` - Creates and cleans up test notes automatically
110+
- `temporary_addressbook` - Creates and cleans up test address books
111+
- `temporary_contact` - Creates and cleans up test contacts
112+
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
113+
114+
### Configuration Files
115+
116+
- **`pyproject.toml`** - Python project configuration using uv for dependency management
117+
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
118+
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database

nextcloud_mcp_server/client/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ async def wrapper(*args, **kwargs):
3939
f"429 Client Error: Too Many Requests, Number of attempts: {retries}"
4040
)
4141
time.sleep(5)
42+
elif e.response.status_code == 404:
43+
# 404 errors are often expected (e.g., checking if attachments exist)
44+
# Log as debug instead of warning
45+
logger.debug(
46+
f"HTTPStatusError {e.response.status_code}: {e}, Number of attempts: {retries}"
47+
)
48+
raise
4249
else:
4350
logger.warning(
4451
f"HTTPStatusError {e.response.status_code}: {e}, Number of attempts: {retries}"

nextcloud_mcp_server/client/calendar.py

Lines changed: 136 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -238,27 +238,33 @@ async def update_event(
238238
event_data: Dict[str, Any],
239239
etag: str = "",
240240
) -> Dict[str, Any]:
241-
"""Update an existing calendar event."""
241+
"""Update an existing calendar event while preserving all existing properties."""
242242
event_filename = f"{event_uid}.ics"
243243
event_path = f"{self._get_caldav_base_path()}/{calendar_name}/{event_filename}"
244244

245-
# Get existing event data to merge with updates
246-
existing_event_data = {}
245+
# Get raw iCal content to preserve all properties including extended ones
246+
raw_ical_content = ""
247247
if not etag:
248248
try:
249-
existing_event_data, current_etag = await self.get_event(
249+
raw_ical_content, current_etag = await self._get_raw_ical(
250250
calendar_name, event_uid
251251
)
252252
etag = current_etag
253253
except Exception:
254-
# Continue without etag if we can't get it
255-
pass
256-
257-
# Merge existing data with new data (new data takes precedence)
258-
merged_data = {**existing_event_data, **event_data}
254+
# Fall back to creating new iCal if we can't get existing
255+
logger.warning(
256+
f"Could not fetch existing iCal for {event_uid}, creating new"
257+
)
258+
raw_ical_content = ""
259259

260-
# Create updated iCalendar event
261-
ical_content = self._create_ical_event(merged_data, event_uid)
260+
# Create updated iCalendar event preserving existing properties
261+
if raw_ical_content:
262+
ical_content = self._merge_ical_properties(
263+
raw_ical_content, event_data, event_uid
264+
)
265+
else:
266+
# Fallback to creating new iCal if we couldn't get existing
267+
ical_content = self._create_ical_event(event_data, event_uid)
262268

263269
headers = {
264270
"Content-Type": "text/calendar; charset=utf-8",
@@ -949,3 +955,122 @@ async def delete_calendar(self, calendar_name: str) -> Dict[str, Any]:
949955
except Exception as e:
950956
logger.error(f"Error deleting calendar {calendar_name}: {e}")
951957
raise
958+
959+
async def _get_raw_ical(
960+
self, calendar_name: str, event_uid: str
961+
) -> Tuple[str, str]:
962+
"""Get raw iCal content for an event without parsing."""
963+
event_filename = f"{event_uid}.ics"
964+
event_path = f"{self._get_caldav_base_path()}/{calendar_name}/{event_filename}"
965+
966+
headers = {"Accept": "text/calendar"}
967+
968+
try:
969+
response = await self._make_request("GET", event_path, headers=headers)
970+
etag = response.headers.get("etag", "")
971+
return response.text, etag
972+
except Exception as e:
973+
logger.error(f"Error getting raw iCal for {event_uid}: {e}")
974+
raise
975+
976+
def _merge_ical_properties(
977+
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
978+
) -> str:
979+
"""Merge new event data into existing raw iCal while preserving all properties."""
980+
try:
981+
# Parse existing iCal
982+
cal = Calendar.from_ical(raw_ical)
983+
984+
# Find the VEVENT component
985+
for component in cal.walk():
986+
if component.name == "VEVENT":
987+
# Update only the properties that were provided in event_data
988+
if "title" in event_data:
989+
component["SUMMARY"] = event_data["title"]
990+
if "description" in event_data:
991+
component["DESCRIPTION"] = event_data["description"]
992+
if "location" in event_data:
993+
component["LOCATION"] = event_data["location"]
994+
if "status" in event_data:
995+
component["STATUS"] = event_data["status"].upper()
996+
if "priority" in event_data:
997+
component["PRIORITY"] = event_data["priority"]
998+
if "privacy" in event_data:
999+
component["CLASS"] = event_data["privacy"].upper()
1000+
if "url" in event_data:
1001+
component["URL"] = event_data["url"]
1002+
1003+
# Handle dates
1004+
if "start_datetime" in event_data:
1005+
start_str = event_data["start_datetime"]
1006+
all_day = event_data.get("all_day", False)
1007+
if all_day:
1008+
start_date = dt.datetime.fromisoformat(
1009+
start_str.split("T")[0]
1010+
).date()
1011+
component["DTSTART"] = start_date
1012+
else:
1013+
start_dt = dt.datetime.fromisoformat(
1014+
start_str.replace("Z", "+00:00")
1015+
)
1016+
component["DTSTART"] = start_dt
1017+
1018+
if "end_datetime" in event_data:
1019+
end_str = event_data["end_datetime"]
1020+
all_day = event_data.get("all_day", False)
1021+
if all_day:
1022+
end_date = dt.datetime.fromisoformat(
1023+
end_str.split("T")[0]
1024+
).date()
1025+
component["DTEND"] = end_date
1026+
else:
1027+
end_dt = dt.datetime.fromisoformat(
1028+
end_str.replace("Z", "+00:00")
1029+
)
1030+
component["DTEND"] = end_dt
1031+
1032+
# Handle categories
1033+
if "categories" in event_data:
1034+
categories = event_data["categories"]
1035+
if categories:
1036+
component["CATEGORIES"] = categories.split(",")
1037+
1038+
# Handle recurrence
1039+
if "recurring" in event_data:
1040+
if event_data["recurring"] and "recurrence_rule" in event_data:
1041+
recurrence_rule = event_data["recurrence_rule"]
1042+
if recurrence_rule:
1043+
component["RRULE"] = vRecur.from_ical(recurrence_rule)
1044+
elif not event_data["recurring"]:
1045+
# Remove recurrence if set to False
1046+
if "RRULE" in component:
1047+
del component["RRULE"]
1048+
1049+
# Handle attendees
1050+
if "attendees" in event_data:
1051+
attendees = event_data["attendees"]
1052+
# Remove existing attendees
1053+
component.pop("ATTENDEE", None)
1054+
if attendees:
1055+
for email in attendees.split(","):
1056+
if email.strip():
1057+
component.add("ATTENDEE", f"mailto:{email.strip()}")
1058+
1059+
# Update timestamps in proper iCal format
1060+
from icalendar import vDDDTypes
1061+
1062+
now = dt.datetime.now(dt.UTC)
1063+
component["LAST-MODIFIED"] = vDDDTypes(now)
1064+
component["DTSTAMP"] = vDDDTypes(now)
1065+
1066+
# Preserve all other existing properties (X-*, ORGANIZER, COMMENT, GEO, etc.)
1067+
# by not touching them - they remain in the component
1068+
1069+
break
1070+
1071+
return cal.to_ical().decode("utf-8")
1072+
1073+
except Exception as e:
1074+
logger.error(f"Error merging iCal properties: {e}")
1075+
# Fallback to creating new iCal
1076+
return self._create_ical_event(event_data, event_uid)

0 commit comments

Comments
 (0)