feat: Implement a CalDAV client and integrate it as tools within the FastMCP server for calendar and event management.
This commit is contained in:
3
.env
Normal file
3
.env
Normal 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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
92
README.md
92
README.md
@@ -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
6
main.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from mcp-caldav!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
12
pyproject.toml
Normal file
12
pyproject.toml
Normal 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",
|
||||||
|
]
|
||||||
BIN
src/__pycache__/caldav_client.cpython-312.pyc
Normal file
BIN
src/__pycache__/caldav_client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/caldav_client.cpython-314.pyc
Normal file
BIN
src/__pycache__/caldav_client.cpython-314.pyc
Normal file
Binary file not shown.
121
src/caldav_client.py
Normal file
121
src/caldav_client.py
Normal 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
119
src/server.py
Normal 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
65
test_client.py
Normal 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()
|
||||||
Reference in New Issue
Block a user