feat: Implement a CalDAV client and integrate it as tools within the FastMCP server for calendar and event management.

This commit is contained in:
Lago
2026-01-18 22:30:14 +01:00
parent 9f60aafd15
commit d8cb818040
11 changed files with 1426 additions and 1 deletions

3
.env Normal file
View File

@@ -0,0 +1,3 @@
CALDAV_URL=http://192.168.178.3:8010/dav.php
CALDAV_USERNAME=Lago
CALDAV_PASSWORD=3QmEa2u8*^krCCO6

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

View File

@@ -1,2 +1,92 @@
# MCP-CalDAV
# MCP CalDAV Server
An Model Context Protocol (MCP) server that connects to a CalDAV server (tested with Baikal) to manage calendars and events.
## Features
- **List Calendars**: View all available calendars.
- **List Events**:
- Filter by specific calendar or search **globally across all calendars**.
- Filter by time range (start/end date).
- **Manage Events**:
- Create new events.
- Update existing events.
- Delete events.
## Prerequisites
- **Python 3.14** or higher
- **uv** (Universal Python Package Manager)
## Installation
1. Clone the repository:
```bash
git clone <repository_url>
cd MCP-CalDAV
```
2. Install dependencies:
```bash
uv sync
```
## Configuration
Create a `.env` file in the root directory with your CalDAV credentials:
```env
CALDAV_URL=http://your-server:port/dav.php
CALDAV_USERNAME=YourUsername
CALDAV_PASSWORD=YourPassword
```
## Usage
### Running Manually
You can run the server directly using `uv`. It communicates via standard input/output (stdio), so running it directly in a terminal will just wait for input.
```bash
uv run src/server.py
```
### Configuring an MCP Client
To use this with an MCP client (like Claude Desktop or another MCP-compatible app), add the following configuration to your client's settings (e.g., `claude_desktop_config.json`):
```json
{
"mcpServers": {
"caldav": {
"command": "uv",
"args": [
"--directory",
"C:\\Users\\LagoWorkStation\\OneDrive\\Documentos\\MCP-CalDAV",
"run",
"src/server.py"
]
}
}
}
```
*Note: Adjust the absolute path to point to your project directory.*
## Available Tools
- **`list_calendars()`**
- Returns a list of all calendars the user has access to.
- **`list_events(calendar_name=None, start_date=None, end_date=None)`**
- Lists events.
- `calendar_name` (optional): Name of the calendar. If omitted, searches **ALL** calendars.
- `start_date` (optional): Start of the range (YYYY-MM-DD or ISO).
- `end_date` (optional): End of the range.
- **`create_event(calendar_name, summary, start_time, end_time, description="")`**
- Creates an event.
- **`update_event(calendar_name, event_uid, summary=None, start_time=None, end_time=None, description=None)`**
- Updates an event by UID.
- **`delete_event(calendar_name, event_uid)`**
- Deletes an event by UID.

6
main.py Normal file
View File

@@ -0,0 +1,6 @@
def main():
print("Hello from mcp-caldav!")
if __name__ == "__main__":
main()

12
pyproject.toml Normal file
View File

@@ -0,0 +1,12 @@
[project]
name = "mcp-caldav"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"caldav>=2.2.3",
"icalendar>=6.3.2",
"mcp>=1.25.0",
"python-dotenv>=1.2.1",
]

Binary file not shown.

Binary file not shown.

121
src/caldav_client.py Normal file
View File

@@ -0,0 +1,121 @@
import os
import caldav
from datetime import datetime
from typing import List, Optional, Dict, Any
from dotenv import load_dotenv
import icalendar
load_dotenv()
class CalDAVClient:
def __init__(self):
self.url = os.getenv("CALDAV_URL")
self.username = os.getenv("CALDAV_USERNAME")
self.password = os.getenv("CALDAV_PASSWORD")
if not all([self.url, self.username, self.password]):
raise ValueError("Missing CalDAV credentials in .env")
self.client = caldav.DAVClient(
url=self.url,
username=self.username,
password=self.password
)
self.principal = self.client.principal()
def list_calendars(self) -> List[Dict[str, str]]:
calendars = self.principal.calendars()
return [{"name": c.name, "url": str(c.url)} for c in calendars]
def _get_calendar(self, name: str):
calendars = self.principal.calendars()
for c in calendars:
if c.name == name:
return c
raise ValueError(f"Calendar '{name}' not found")
def list_events(self, calendar_name: Optional[str] = None, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None) -> List[Dict[str, Any]]:
if calendar_name:
calendars = [self._get_calendar(calendar_name)]
else:
calendars = self.principal.calendars()
results = []
for calendar in calendars:
if start_date and end_date:
events = calendar.date_search(start=start_date, end=end_date, expand=True)
else:
events = calendar.events()
for event in events:
# event.data is the raw ical data
ical = icalendar.Calendar.from_ical(event.data)
for component in ical.walk():
if component.name == "VEVENT":
summary = component.get("summary")
description = component.get("description")
dtstart = component.get("dtstart").dt
dtend = component.get("dtend").dt if component.get("dtend") else None
uid = component.get("uid")
# Handle datetime/date objects (convert to isoformat string)
start_str = dtstart.isoformat() if hasattr(dtstart, 'isoformat') else str(dtstart)
end_str = dtend.isoformat() if dtend and hasattr(dtend, 'isoformat') else str(dtend) if dtend else None
results.append({
"calendar": calendar.name,
"uid": str(uid),
"summary": str(summary) if summary else "No Title",
"description": str(description) if description else "",
"start": start_str,
"end": end_str
})
return results
def create_event(self, calendar_name: str, summary: str, start_time: datetime, end_time: datetime, description: str = "") -> str:
calendar = self._get_calendar(calendar_name)
event = calendar.save_event(
dtstart=start_time,
dtend=end_time,
summary=summary,
description=description
)
# Re-parse to get the UID, though library might provide it
ical = icalendar.Calendar.from_ical(event.data)
for component in ical.walk():
if component.name == "VEVENT":
return str(component.get("uid"))
return "Event Created (UID Unknown)"
def update_event(self, calendar_name: str, event_uid: str, summary: Optional[str] = None, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, description: Optional[str] = None) -> bool:
calendar = self._get_calendar(calendar_name)
event = calendar.event_by_uid(event_uid)
# We need to modify the underlying ical component
ical = icalendar.Calendar.from_ical(event.data)
changed = False
for component in ical.walk():
if component.name == "VEVENT":
if summary:
component["summary"] = summary
changed = True
if description is not None:
component["description"] = description
changed = True
if start_time:
component["dtstart"] = icalendar.vDatetime(start_time)
changed = True
if end_time:
component["dtend"] = icalendar.vDatetime(end_time)
changed = True
if changed:
event.data = ical.to_ical()
event.save()
return True
return False
def delete_event(self, calendar_name: str, event_uid: str):
calendar = self._get_calendar(calendar_name)
event = calendar.event_by_uid(event_uid)
event.delete()

119
src/server.py Normal file
View File

@@ -0,0 +1,119 @@
from mcp.server.fastmcp import FastMCP
from src.caldav_client import CalDAVClient
from datetime import datetime
from typing import Optional
# Initialize FastMCP server
mcp = FastMCP("CalDAV Server")
# Initialize CalDAV client
# We initialize it lazily or here if credentials are guaranteed to be present
try:
client = CalDAVClient()
except Exception as e:
import sys
sys.stderr.write(f"Error initializing CalDAV client: {e}\n")
client = None
@mcp.tool()
def list_calendars() -> str:
"""Lists all available calendars on the CalDAV server."""
if not client:
return "Error: CalDAV client not initialized (check credentials)"
try:
calendars = client.list_calendars()
return str(calendars)
except Exception as e:
return f"Error listing calendars: {str(e)}"
@mcp.tool()
def list_events(calendar_name: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None) -> str:
"""
Lists events. If calendar_name is not provided, lists events from ALL calendars.
Args:
calendar_name: Optional. The name of the calendar to query.
start_date: Optional start date in YYYY-MM-DD or ISO format.
end_date: Optional end date in YYYY-MM-DD or ISO format.
"""
if not client:
return "Error: CalDAV client not initialized"
try:
dt_start = datetime.fromisoformat(start_date) if start_date else None
dt_end = datetime.fromisoformat(end_date) if end_date else None
events = client.list_events(calendar_name, dt_start, dt_end)
return str(events)
except Exception as e:
return f"Error listing events: {str(e)}"
@mcp.tool()
def create_event(calendar_name: str, summary: str, start_time: str, end_time: str, description: str = "") -> str:
"""
Creates a new event.
Args:
calendar_name: Target calendar.
summary: Event title.
start_time: Start time (ISO format).
end_time: End time (ISO format).
description: Event description.
"""
if not client:
return "Error: CalDAV client not initialized"
try:
dt_start = datetime.fromisoformat(start_time)
dt_end = datetime.fromisoformat(end_time)
result = client.create_event(calendar_name, summary, dt_start, dt_end, description)
return f"Event created: {result}"
except Exception as e:
return f"Error creating event: {str(e)}"
@mcp.tool()
def update_event(calendar_name: str, event_uid: str, summary: Optional[str] = None, start_time: Optional[str] = None, end_time: Optional[str] = None, description: Optional[str] = None) -> str:
"""
Updates an existing event.
Args:
calendar_name: Calendar containing the event.
event_uid: UID of the event to update.
summary: New title (optional).
start_time: New start time (ISO format, optional).
end_time: New end time (ISO format, optional).
description: New description (optional).
"""
if not client:
return "Error: CalDAV client not initialized"
try:
dt_start = datetime.fromisoformat(start_time) if start_time else None
dt_end = datetime.fromisoformat(end_time) if end_time else None
success = client.update_event(calendar_name, event_uid, summary, dt_start, dt_end, description)
return "Event updated successfully" if success else "Event update failed (or no changes made)"
except Exception as e:
return f"Error updating event: {str(e)}"
@mcp.tool()
def delete_event(calendar_name: str, event_uid: str) -> str:
"""
Deletes an event.
Args:
calendar_name: Calendar containing the event.
event_uid: UID of the event to delete.
"""
if not client:
return "Error: CalDAV client not initialized"
try:
client.delete_event(calendar_name, event_uid)
return "Event deleted successfully"
except Exception as e:
return f"Error deleting event: {str(e)}"
if __name__ == "__main__":
mcp.run()

65
test_client.py Normal file
View File

@@ -0,0 +1,65 @@
from src.caldav_client import CalDAVClient
from datetime import datetime, timedelta
import os
def test_client():
print("Testing CalDAV Client...")
try:
client = CalDAVClient()
print("Client initialized.")
print("\nListing Calendars:")
calendars = client.list_calendars()
for c in calendars:
print(f"- {c['name']} ({c['url']})")
if not calendars:
print("No calendars found.")
return
test_calendar = calendars[0]['name']
print(f"\nUsing calendar: {test_calendar}")
print("\nCreating Test Event...")
start = datetime.now()
end = start + timedelta(hours=1)
uid = client.create_event(test_calendar, "MCP Test Event", start, end, "This is a test event created by MCP")
print(f"Event created with UID: {uid}")
print("\nListing Events (Verification):")
events = client.list_events(test_calendar)
found = False
for e in events:
if e['uid'] == uid:
print(f"Found event: {e['summary']} - {e['start']}")
found = True
break
if found:
print("\nUpdating Event...")
new_summary = "MCP Test Event (Updated)"
client.update_event(test_calendar, uid, summary=new_summary)
print("Event updated.")
print("\nListing ALL Events (Global Filter Test):")
all_events = client.list_events() # No calendar name provided
global_found = False
for e in all_events:
if e['uid'] == uid:
print(f"Found event in global list: {e['summary']} (Calendar: {e['calendar']})")
global_found = True
break
if not global_found:
print("Error: Created event not found in global list.")
print("\nDeleting Event...")
client.delete_event(test_calendar, uid)
print("Event deleted.")
else:
print("Error: Created event not found in list.")
except Exception as e:
print(f"Test failed: {e}")
if __name__ == "__main__":
test_client()

1008
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff