feat(google-calendar): add RRULE recurrence, transparency, reminders, move_event, and free-slots finder

This commit is contained in:
Moritz Graf
2026-09-18 11:21:35 +02:00
parent a9d386c2e9
commit ea079c9852
2 changed files with 585 additions and 39 deletions
+42 -18
View File
@@ -33,30 +33,45 @@ OpenClaw assigns Google Calendar colors automatically based on event titles or e
## 🛠️ MCP Tools
You have access to 6 dedicated calendar tools:
You have access to 8 dedicated calendar tools:
1. `calendar_list_events(calendar="family"|"regensburg", time_min=None, time_max=None, query=None, max_results=25)`:
- Lists appointments within a timeframe including start/end, color name, location, and description.
- `time_min` / `time_max`: ISO format (e.g. `2026-09-20` or `2026-09-20T08:00:00`). Defaults to start of today until 30 days ahead.
1. `calendar_list_events(calendar="family"|"regensburg", time_min=None, time_max=None, query=None, category=None, color_id=None, max_results=25)`:
- Lists appointments within a timeframe including start/end, color name, location, recurrence status, and description.
- `time_min` / `time_max`: ISO format (e.g. `2026-09-20` or `2026-09-20T08:00:00`). Defaults to start of today until 30 days ahead. Automatically expands recurring events into individual occurrences.
- `category` / `color_id`: Filter by person or category (e.g. `category="arzt"` or `color_id="11"`).
- `query`: Text filter to search summaries and descriptions.
2. `calendar_check_conflicts(calendar="family"|"regensburg", start_time, end_time=None, buffer_minutes=15)`:
- Proactively checks if a proposed slot conflicts with existing events or leaves less than `buffer_minutes` travel buffer.
- Returns `has_conflict`, `direct_conflicts`, `has_buffer_warning`, and `buffer_warnings`.
3. `calendar_create_event(calendar="family"|"regensburg", summary, start_time, end_time=None, category=None, color_id=None, description=None, location=None, all_day=False, check_conflicts=True)`:
- Adds a new appointment with automatic color coding and overlap detection.
- If conflicts or tight buffers are detected, a warning is returned in `conflict_warning`.
- For timed events: pass ISO datetime (e.g. `2026-09-20T14:30:00`). If `end_time` is omitted, defaults to 1 hour after start.
- For all-day events: set `all_day=True` and provide date `YYYY-MM-DD`.
3. `calendar_create_event(calendar="family"|"regensburg", summary, start_time, end_time=None, recurrence=None, show_as_busy=True, transparency=None, reminder_minutes=None, category=None, color_id=None, description=None, location=None, all_day=False, inclusive_end=True, check_conflicts=True)`:
- Adds a new appointment with automatic color coding, recurrence, transparency, phone reminders, and overlap detection.
- **`recurrence`**: Standard RFC 5545 rule or shortcuts:
- `recurrence="RRULE:FREQ=WEEKLY;BYDAY=SA"` (weekly Saturday market)
- `recurrence="RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA;UNTIL=20261231T230000Z"` (daily except Sun until year end)
- Shortcuts: `"weekly"`, `"daily"`, `"monthly"`, `"workdays"`.
- **`show_as_busy` / `transparency`**:
- For **Regensburg city events, markets, festivals, exhibitions**: set `show_as_busy=False` or `transparency="transparent"` so they are visible on the calendar without blocking family schedule slots or triggering false conflict warnings!
- **`reminder_minutes`**: List of popup alert minutes before event (e.g. `[15, 60]` or `1440` for 1 day before).
- **`inclusive_end`**: For multi-day all-day events, automatically includes the end date without off-by-one errors.
4. `calendar_update_event(calendar="family"|"regensburg", event_id, summary=None, start_time=None, end_time=None, category=None, color_id=None, description=None, location=None)`:
- Updates fields or recolors an existing event.
4. `calendar_update_event(calendar="family"|"regensburg", event_id, summary=None, start_time=None, end_time=None, recurrence=None, show_as_busy=None, transparency=None, reminder_minutes=None, update_series=False, category=None, color_id=None, description=None, location=None)`:
- Updates fields, recurrence, transparency, reminders, or recolors an existing event.
- `update_series=True`: If event is an occurrence of a series, updates the entire master series instead of just this single occurrence.
5. `calendar_delete_event(calendar="family"|"regensburg", event_id)`:
5. `calendar_delete_event(calendar="family"|"regensburg", event_id, delete_series=False)`:
- Deletes an event.
- `delete_series=True`: If event is part of a recurring series, deletes the entire recurring series instead of just this single occurrence.
6. `calendar_get_event(calendar="family"|"regensburg", event_id)`:
6. `calendar_move_event(event_id, source_calendar="family", destination_calendar="regensburg")`:
- Moves an event atomically from one calendar to another (e.g. turning an interesting city event from Regensburg into a fixed family plan, or correcting a wrongly booked calendar).
7. `calendar_find_free_slots(calendar="family"|"regensburg"|"both", time_min=None, time_max=None, duration_minutes=60, day_start_hour=8, day_end_hour=20)`:
- Queries `freebusy` to find open, available time windows across calendars.
- Perfect for answering questions like: *"Wann haben wir diese Woche am Nachmittag 2 Stunden frei für einen Ausflug?"*.
8. `calendar_get_event(calendar="family"|"regensburg", event_id)`:
- Retrieves full event metadata.
---
@@ -68,12 +83,21 @@ You have access to 6 dedicated calendar tools:
- If an overlap or tight buffer (< 15 Min.) exists, explicitly warn the user:
> *„⚠️ Achtung: Am Freitag um 17:30 Uhr hat Moritz schon 'Moritz Gartenparty Arbeit' (17:30 - 18:30). Dein Termin 'Spielplatz' würde sich damit überschneiden. Soll ich ihn trotzdem eintragen?“*
### 2. Smart Location & Google Maps Integration
### 2. Informational Events vs. Family Commitments (Transparency)
- When adding city events, festivals, or weekly markets to the **`regensburg`** calendar, always pass `show_as_busy=False` (or `transparency="transparent"`).
- This keeps the event clearly visible on the calendar while allowing family appointments to be scheduled in parallel without false conflict alerts.
### 3. Recurring Series (RRULE)
- When the user asks to add recurring appointments (e.g. weekly markets, sports training, trash collection):
- Use `recurrence="RRULE:FREQ=WEEKLY;BYDAY=..."` or convenient shortcuts.
- Do NOT create manual cron scripts or multiple single events when a native Google Calendar recurrence series can be used.
### 4. Smart Location & Google Maps Integration
- If a place or venue is mentioned (e.g. *"Dr. Müller in Prüfening"*, *"Westbad"*):
- Resolve the full address or ask the user, and store it in `location`.
- This activates Google Maps departure alerts on their smartphone (*„Aufbruch in 15 Min. wegen Verkehr“*).
### 3. Proaktive Rückfragen & Checklisten (Mental Load Entlastung)
### 5. Proaktive Rückfragen & Checklisten (Mental Load Entlastung)
- When booking medical/baby appointments (e.g. *Kinderarzt*, *U-Untersuchungen*, *Zahnarzt*):
- Auto-generate or suggest a helpful checklist in `description`:
```text
@@ -82,8 +106,8 @@ You have access to 6 dedicated calendar tools:
- Versichertenkarte
- 2x Wickelzeug & Schnuller
```
- Ask proactively in Telegram:
> *„Termin eingetragen! 🏥 Soll ich noch eine Notiz oder Packliste (U-Heft, Impfpass, Wechselsachen) im Termin hinterlegen?“*
- Suggest a reminder (`reminder_minutes=[60, 1440]`):
> *„Termin eingetragen! 🏥 Ich habe eine Erinnerung 1 Stunde vorher eingerichtet. Soll ich noch eine Packliste im Termin hinterlegen?“*
### 4. Timezone & DateTime Precision
### 6. Timezone & DateTime Precision
- All dates and times operate in **Europe/Berlin** (`CET`/`CEST`).
+543 -21
View File
@@ -14,7 +14,7 @@ import sys
import json
import argparse
from datetime import datetime, date, timedelta
from typing import Dict, Any, List, Optional, Tuple
from typing import Dict, Any, List, Optional, Tuple, Union
from zoneinfo import ZoneInfo
# Environment Configuration
@@ -205,6 +205,88 @@ def parse_datetime_str(dt_str: str) -> datetime:
return dt
def normalize_recurrence(val: Any) -> Optional[List[str]]:
"""
Normalize recurrence input into a list of valid RFC 5545 RRULE/EXRULE strings
accepted by Google Calendar API.
"""
if val is None or isinstance(val, bool):
return None
raw_rules: List[str] = []
if isinstance(val, str):
val_str = val.strip()
if not val_str:
return None
lower = val_str.lower()
if lower in ("weekly", "wöchentlich"):
return ["RRULE:FREQ=WEEKLY"]
elif lower in ("daily", "täglich"):
return ["RRULE:FREQ=DAILY"]
elif lower in ("monthly", "monatlich"):
return ["RRULE:FREQ=MONTHLY"]
elif lower in ("yearly", "jährlich", "annual"):
return ["RRULE:FREQ=YEARLY"]
elif lower in ("workdays", "werktags", "werktäglich"):
return ["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"]
elif lower in ("weekend", "wochenende"):
return ["RRULE:FREQ=WEEKLY;BYDAY=SA,SU"]
raw_rules = [r.strip() for r in val_str.split("\n") if r.strip()]
elif isinstance(val, (list, tuple)):
raw_rules = [str(r).strip() for r in val if str(r).strip()]
else:
return None
if not raw_rules:
return None
cleaned_rules = []
for rule in raw_rules:
rule_upper = rule.upper()
if any(rule_upper.startswith(p) for p in ("RRULE:", "EXRULE:", "RDATE:", "EXDATE:")):
cleaned_rules.append(rule)
elif any(rule_upper.startswith(p) for p in ("FREQ=", "UNTIL=", "COUNT=", "INTERVAL=", "BYDAY=", "WKST=")):
cleaned_rules.append(f"RRULE:{rule}")
else:
cleaned_rules.append(f"RRULE:{rule}")
return cleaned_rules if cleaned_rules else None
def normalize_reminders(val: Any) -> Optional[Dict[str, Any]]:
"""
Normalize reminder minutes into Google Calendar API reminders resource:
{"useDefault": False, "overrides": [{"method": "popup", "minutes": X}, ...]}
"""
if val is None:
return None
minutes_list: List[int] = []
if isinstance(val, int):
minutes_list = [val]
elif isinstance(val, (list, tuple)):
for item in val:
try:
minutes_list.append(int(item))
except (ValueError, TypeError):
continue
elif isinstance(val, str):
val_str = val.strip()
if val_str:
for part in val_str.split(","):
part = part.strip()
try:
minutes_list.append(int(part))
except (ValueError, TypeError):
continue
return {
"useDefault": False,
"overrides": [{"method": "popup", "minutes": m} for m in minutes_list]
}
def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]:
"""Format an event item into a clean representation including color labels."""
start = item.get("start", {})
@@ -217,6 +299,17 @@ def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]:
color_id = item.get("colorId")
color_label = COLOR_NAMES.get(color_id) if color_id else None
recurrence = item.get("recurrence")
recurring_event_id = item.get("recurringEventId")
is_recurring = bool(recurrence or recurring_event_id)
transparency = item.get("transparency", "opaque")
show_as_busy = (transparency != "transparent")
rem = item.get("reminders", {})
use_default_rem = rem.get("useDefault", True)
reminder_minutes = [ov.get("minutes") for ov in rem.get("overrides", []) if "minutes" in ov] if not use_default_rem else None
return {
"id": item.get("id"),
"calendar": calendar_name,
@@ -224,6 +317,15 @@ def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]:
"start": start_str,
"end": end_str,
"all_day": is_all_day,
"is_recurring": is_recurring,
"recurrence": recurrence,
"recurring_event_id": recurring_event_id,
"show_as_busy": show_as_busy,
"transparency": transparency,
"reminders": {
"use_default": use_default_rem,
"reminder_minutes": reminder_minutes,
},
"color_id": color_id,
"color_name": color_label,
"description": item.get("description", ""),
@@ -336,9 +438,11 @@ def list_events(
time_min: Optional[str] = None,
time_max: Optional[str] = None,
query: Optional[str] = None,
category: Optional[str] = None,
color_id: Optional[str] = None,
max_results: int = 25,
) -> Dict[str, Any]:
"""List events from the specified calendar."""
"""List events from the specified calendar with optional query, category, and color filters."""
service = get_calendar_service()
cal_id = resolve_calendar_id(calendar)
@@ -355,7 +459,7 @@ def list_events(
else:
time_max_str = parse_datetime_str(time_max).isoformat()
params = {
params: Dict[str, Any] = {
"calendarId": cal_id,
"timeMin": time_min_str,
"timeMax": time_max_str,
@@ -369,12 +473,19 @@ def list_events(
res = service.events().list(**params).execute()
items = res.get("items", [])
# Filter by category / colorId if requested
target_color_id = color_id or (resolve_color_id("", explicit_category_or_color=category) if category else None)
if target_color_id:
items = [it for it in items if it.get("colorId") == target_color_id]
events = [format_event(item, calendar) for item in items]
return {
"calendar": calendar,
"calendar_id": cal_id,
"time_min": time_min_str,
"time_max": time_max_str,
"filter_category": category,
"filter_color_id": target_color_id,
"count": len(events),
"events": events,
}
@@ -390,12 +501,17 @@ def create_event(
all_day: bool = False,
category: Optional[str] = None,
color_id: Optional[str] = None,
recurrence: Optional[Any] = None,
show_as_busy: Optional[bool] = None,
transparency: Optional[str] = None,
reminder_minutes: Optional[Any] = None,
inclusive_end: bool = True,
check_conflicts_flag: bool = True,
buffer_minutes: int = 15,
) -> Dict[str, Any]:
"""
Create a new event in the specified calendar with automatic color-coding
and conflict detection.
Create a new event in the specified calendar with automatic color-coding,
conflict detection, recurrence (RRULE), transparency, and custom reminders.
"""
if not summary:
raise ValueError("Parameter 'summary' is required.")
@@ -429,12 +545,37 @@ def create_event(
if chosen_color_id:
body["colorId"] = chosen_color_id
# Recurrence rules (RFC 5545 RRULE)
norm_rec = normalize_recurrence(recurrence)
if norm_rec:
body["recurrence"] = norm_rec
if conflict_info and (conflict_info["has_conflict"] or conflict_info["has_buffer_warning"]):
conflict_info["recurring_note"] = "Conflict check was performed on the initial occurrence only."
# Event Transparency (Free vs. Busy)
if transparency:
body["transparency"] = transparency.lower().strip()
elif show_as_busy is False:
body["transparency"] = "transparent"
elif show_as_busy is True:
body["transparency"] = "opaque"
# Custom Reminders
norm_rem = normalize_reminders(reminder_minutes)
if norm_rem is not None:
body["reminders"] = norm_rem
if all_day:
start_date = start_time.split("T")[0]
dt_start = date.fromisoformat(start_date)
if end_time:
end_date = end_time.split("T")[0]
end_date_raw = end_time.split("T")[0]
dt_end_raw = date.fromisoformat(end_date_raw)
if inclusive_end and dt_end_raw >= dt_start:
end_date = (dt_end_raw + timedelta(days=1)).isoformat()
else:
end_date = dt_end_raw.isoformat()
else:
dt_start = date.fromisoformat(start_date)
end_date = (dt_start + timedelta(days=1)).isoformat()
body["start"] = {"date": start_date}
@@ -478,15 +619,30 @@ def update_event(
location: Optional[str] = None,
category: Optional[str] = None,
color_id: Optional[str] = None,
recurrence: Optional[Any] = None,
show_as_busy: Optional[bool] = None,
transparency: Optional[str] = None,
reminder_minutes: Optional[Any] = None,
inclusive_end: bool = True,
update_series: bool = False,
) -> Dict[str, Any]:
"""Update an existing event."""
"""Update an existing event or series."""
if not event_id:
raise ValueError("Parameter 'event_id' is required.")
service = get_calendar_service()
cal_id = resolve_calendar_id(calendar)
existing = service.events().get(calendarId=cal_id, eventId=event_id).execute()
target_id = event_id
if update_series:
try:
item_check = service.events().get(calendarId=cal_id, eventId=event_id).execute()
if item_check.get("recurringEventId"):
target_id = item_check["recurringEventId"]
except Exception:
pass
existing = service.events().get(calendarId=cal_id, eventId=target_id).execute()
if summary is not None:
existing["summary"] = summary
@@ -499,11 +655,36 @@ def update_event(
if new_color:
existing["colorId"] = new_color
if recurrence is not None:
if recurrence == [] or recurrence == "" or recurrence is False:
existing.pop("recurrence", None)
else:
norm_rec = normalize_recurrence(recurrence)
if norm_rec:
existing["recurrence"] = norm_rec
if transparency is not None:
existing["transparency"] = transparency.lower().strip()
elif show_as_busy is not None:
existing["transparency"] = "opaque" if show_as_busy else "transparent"
if reminder_minutes is not None:
norm_rem = normalize_reminders(reminder_minutes)
if norm_rem is not None:
existing["reminders"] = norm_rem
if start_time is not None:
if "date" in existing.get("start", {}):
existing["start"]["date"] = start_time.split("T")[0]
start_date_str = start_time.split("T")[0]
existing["start"]["date"] = start_date_str
dt_start = date.fromisoformat(start_date_str)
if end_time:
existing["end"]["date"] = end_time.split("T")[0]
end_date_raw = end_time.split("T")[0]
dt_end_raw = date.fromisoformat(end_date_raw)
if inclusive_end and dt_end_raw >= dt_start:
existing["end"]["date"] = (dt_end_raw + timedelta(days=1)).isoformat()
else:
existing["end"]["date"] = end_date_raw
else:
dt_start = parse_datetime_str(start_time)
existing["start"] = {
@@ -517,26 +698,187 @@ def update_event(
"timeZone": DEFAULT_TIMEZONE_STR,
}
updated = service.events().update(calendarId=cal_id, eventId=event_id, body=existing).execute()
updated = service.events().update(calendarId=cal_id, eventId=target_id, body=existing).execute()
return {
"status": "updated",
"event": format_event(updated, calendar),
"updated_series": (target_id != event_id or update_series),
}
def delete_event(calendar: str = "family", event_id: str = "") -> Dict[str, Any]:
"""Delete an event from the specified calendar."""
def delete_event(
calendar: str = "family",
event_id: str = "",
delete_series: bool = False,
) -> Dict[str, Any]:
"""Delete an event or recurring series from the specified calendar."""
if not event_id:
raise ValueError("Parameter 'event_id' is required.")
service = get_calendar_service()
cal_id = resolve_calendar_id(calendar)
service.events().delete(calendarId=cal_id, eventId=event_id).execute()
target_id = event_id
if delete_series:
try:
item = service.events().get(calendarId=cal_id, eventId=event_id).execute()
if item.get("recurringEventId"):
target_id = item["recurringEventId"]
except Exception:
pass
service.events().delete(calendarId=cal_id, eventId=target_id).execute()
return {
"status": "deleted",
"calendar": calendar,
"event_id": event_id,
"event_id": target_id,
"deleted_series": (target_id != event_id or delete_series),
}
def move_event(
event_id: str,
source_calendar: str = "family",
destination_calendar: str = "regensburg",
) -> Dict[str, Any]:
"""Move an event atomically between calendars (e.g. from regensburg to family or vice versa)."""
if not event_id:
raise ValueError("Parameter 'event_id' is required.")
service = get_calendar_service()
src_id = resolve_calendar_id(source_calendar)
dst_id = resolve_calendar_id(destination_calendar)
moved = service.events().move(
calendarId=src_id,
eventId=event_id,
destination=dst_id,
).execute()
return {
"status": "moved",
"source_calendar": source_calendar,
"destination_calendar": destination_calendar,
"event": format_event(moved, destination_calendar),
}
def find_free_slots(
calendar: str = "family",
time_min: Optional[str] = None,
time_max: Optional[str] = None,
duration_minutes: int = 60,
day_start_hour: int = 8,
day_end_hour: int = 20,
) -> Dict[str, Any]:
"""
Find available free time windows between day_start_hour and day_end_hour
across specified calendar(s) using freebusy.query.
"""
service = get_calendar_service()
now = datetime.now(DEFAULT_TZ)
if not time_min:
dt_start = now
else:
dt_start = parse_datetime_str(time_min)
if not time_max:
dt_end = dt_start + timedelta(days=7)
else:
dt_end = parse_datetime_str(time_max)
# Determine which calendars to query
cal_ids = []
cal_lower = calendar.lower().strip()
if cal_lower in ("both", "alle", "all"):
if CALENDAR_ID_FAMILY:
cal_ids.append(CALENDAR_ID_FAMILY)
if CALENDAR_ID_REGENSBURG:
cal_ids.append(CALENDAR_ID_REGENSBURG)
else:
cal_ids.append(resolve_calendar_id(calendar))
body = {
"timeMin": dt_start.isoformat(),
"timeMax": dt_end.isoformat(),
"timeZone": DEFAULT_TIMEZONE_STR,
"items": [{"id": cid} for cid in cal_ids],
}
fb_res = service.freebusy().query(body=body).execute()
cals_dict = fb_res.get("calendars", {})
busy_intervals = []
for cid, cdata in cals_dict.items():
for b in cdata.get("busy", []):
b_start = parse_datetime_str(b["start"])
b_end = parse_datetime_str(b["end"])
busy_intervals.append((b_start, b_end))
busy_intervals.sort(key=lambda x: x[0])
merged_busy = []
for b_start, b_end in busy_intervals:
if not merged_busy:
merged_busy.append([b_start, b_end])
else:
prev_start, prev_end = merged_busy[-1]
if b_start <= prev_end:
merged_busy[-1][1] = max(prev_end, b_end)
else:
merged_busy.append([b_start, b_end])
free_slots = []
current_day = dt_start.date()
end_day = dt_end.date()
while current_day <= end_day:
window_start = datetime(current_day.year, current_day.month, current_day.day, day_start_hour, 0, 0, tzinfo=DEFAULT_TZ)
window_end = datetime(current_day.year, current_day.month, current_day.day, day_end_hour, 0, 0, tzinfo=DEFAULT_TZ)
eff_start = max(window_start, dt_start)
eff_end = min(window_end, dt_end)
if eff_start < eff_end:
slot_pointer = eff_start
for b_start, b_end in merged_busy:
if b_end <= slot_pointer:
continue
if b_start >= eff_end:
break
if b_start > slot_pointer:
gap_minutes = (b_start - slot_pointer).total_seconds() / 60.0
if gap_minutes >= duration_minutes:
free_slots.append({
"start": slot_pointer.isoformat(),
"end": b_start.isoformat(),
"duration_minutes": int(gap_minutes),
"date": current_day.isoformat(),
})
slot_pointer = max(slot_pointer, b_end)
if slot_pointer < eff_end:
gap_minutes = (eff_end - slot_pointer).total_seconds() / 60.0
if gap_minutes >= duration_minutes:
free_slots.append({
"start": slot_pointer.isoformat(),
"end": eff_end.isoformat(),
"duration_minutes": int(gap_minutes),
"date": current_day.isoformat(),
})
current_day += timedelta(days=1)
return {
"calendar": calendar,
"search_min": dt_start.isoformat(),
"search_max": dt_end.isoformat(),
"requested_duration_minutes": duration_minutes,
"day_hours": f"{day_start_hour:02d}:00 - {day_end_hour:02d}:00",
"slots_found": len(free_slots),
"free_slots": free_slots,
}
@@ -604,7 +946,7 @@ def test_connection() -> Dict[str, Any]:
TOOLS_MANIFEST = [
{
"name": "calendar_list_events",
"description": "List upcoming appointments and events from the Google Calendar. Returns event title, start/end time, color category, location, and description.",
"description": "List upcoming appointments and events from the Google Calendar. Returns event title, start/end time, color category, location, recurrence, and description.",
"inputSchema": {
"type": "object",
"properties": {
@@ -625,6 +967,14 @@ TOOLS_MANIFEST = [
"type": "string",
"description": "Optional search term to filter event titles and descriptions.",
},
"category": {
"type": "string",
"description": "Optional category filter: 'arzt', 'mila', 'moritz', 'amelie', 'familie', 'haushalt'.",
},
"color_id": {
"type": "string",
"description": "Optional colorId filter ('11' for Arzt, '5' for Mila, '9' for Moritz, etc.).",
},
"max_results": {
"type": "integer",
"description": "Maximum number of events to return (default 25).",
@@ -663,7 +1013,7 @@ TOOLS_MANIFEST = [
},
{
"name": "calendar_create_event",
"description": "Create a new appointment in Google Calendar. Supports automatic color-coding (Arzt=Rot, Mila=Gelb, Moritz=Blau, Amelie=Grün, Familie=Orange, Haushalt=Grau), address location, prep checklists, and conflict warnings.",
"description": "Create a new appointment in Google Calendar. Supports automatic color-coding (Arzt=Rot, Mila=Gelb, Moritz=Blau, Amelie=Grün, Familie=Orange, Haushalt=Grau), recurring series (RRULE), transparency (busy vs free), phone notification reminders, address location, checklists, and conflict warnings.",
"inputSchema": {
"type": "object",
"required": ["summary", "start_time"],
@@ -685,6 +1035,21 @@ TOOLS_MANIFEST = [
"type": "string",
"description": "Optional end time in ISO format. If omitted for timed events, defaults to 1 hour after start_time.",
},
"recurrence": {
"description": "Optional recurrence rule(s) for repeating events (e.g. ['RRULE:FREQ=WEEKLY;BYDAY=SA'], 'RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA;UNTIL=20261231T230000Z', or shortcuts like 'weekly', 'daily', 'monthly', 'workdays').",
},
"show_as_busy": {
"type": "boolean",
"description": "Whether this event marks time as busy. Set False for informational city events, festivals, or notes so they don't block family calendar slots or cause false conflict warnings (default True).",
"default": True,
},
"transparency": {
"type": "string",
"description": "Explicit Google transparency: 'opaque' (busy) or 'transparent' (free/available).",
},
"reminder_minutes": {
"description": "Optional notification reminder minutes before event (e.g. [15, 60] or 1440 for 1 day before). Sends popup alert to phone.",
},
"category": {
"type": "string",
"description": "Optional category/person for color-coding: 'arzt', 'mila', 'moritz', 'amelie', 'familie', 'haushalt'. If omitted, auto-detected from summary.",
@@ -706,6 +1071,11 @@ TOOLS_MANIFEST = [
"description": "Whether the event lasts all day (default False).",
"default": False,
},
"inclusive_end": {
"type": "boolean",
"description": "For all-day events with end_time date, whether the end date is inclusive (default True).",
"default": True,
},
"check_conflicts": {
"type": "boolean",
"description": "Whether to check for scheduling overlaps and attach warnings (default True).",
@@ -716,7 +1086,7 @@ TOOLS_MANIFEST = [
},
{
"name": "calendar_update_event",
"description": "Update an existing event in the Google Calendar (title, times, category/color, description, location).",
"description": "Update an existing event or recurring series in the Google Calendar (title, times, recurrence, transparency, reminders, category/color, description, location).",
"inputSchema": {
"type": "object",
"required": ["event_id"],
@@ -742,6 +1112,25 @@ TOOLS_MANIFEST = [
"type": "string",
"description": "New end time in ISO format.",
},
"recurrence": {
"description": "Updated recurrence rule(s), or [] / empty string to remove recurrence.",
},
"show_as_busy": {
"type": "boolean",
"description": "Whether this event marks time as busy (True) or free (False).",
},
"transparency": {
"type": "string",
"description": "Explicit transparency: 'opaque' (busy) or 'transparent' (available).",
},
"reminder_minutes": {
"description": "Updated reminder minutes list before event (e.g. [15, 60]).",
},
"update_series": {
"type": "boolean",
"description": "If True and event is an occurrence of a series, updates the entire master series (default False).",
"default": False,
},
"category": {
"type": "string",
"description": "Category for color-coding ('arzt', 'mila', 'moritz', 'amelie', 'familie', 'haushalt').",
@@ -763,7 +1152,7 @@ TOOLS_MANIFEST = [
},
{
"name": "calendar_delete_event",
"description": "Delete an event from the Google Calendar.",
"description": "Delete an event or recurring series from the Google Calendar.",
"inputSchema": {
"type": "object",
"required": ["event_id"],
@@ -777,6 +1166,72 @@ TOOLS_MANIFEST = [
"type": "string",
"description": "The unique ID of the event to delete.",
},
"delete_series": {
"type": "boolean",
"description": "If True and the event is part of a recurring series, deletes the entire recurring series instead of just this single occurrence (default False).",
"default": False,
},
},
},
},
{
"name": "calendar_move_event",
"description": "Move an event atomically between calendars (e.g. from 'regensburg' to 'family' or vice versa).",
"inputSchema": {
"type": "object",
"required": ["event_id"],
"properties": {
"event_id": {
"type": "string",
"description": "The unique ID of the event to move.",
},
"source_calendar": {
"type": "string",
"description": "Source calendar ('family' or 'regensburg'). Default 'family'.",
"default": "family",
},
"destination_calendar": {
"type": "string",
"description": "Destination calendar ('family' or 'regensburg'). Default 'regensburg'.",
"default": "regensburg",
},
},
},
},
{
"name": "calendar_find_free_slots",
"description": "Find available free time windows across calendars (family, regensburg, or both) using Google Calendar freebusy query.",
"inputSchema": {
"type": "object",
"properties": {
"calendar": {
"type": "string",
"description": "Which calendar(s) to check: 'family' (default), 'regensburg', or 'both'/'all'.",
"default": "family",
},
"time_min": {
"type": "string",
"description": "Start of search timeframe in ISO format (defaults to now).",
},
"time_max": {
"type": "string",
"description": "End of search timeframe in ISO format (defaults to 7 days ahead).",
},
"duration_minutes": {
"type": "integer",
"description": "Minimum length of the free slot required in minutes (default 60).",
"default": 60,
},
"day_start_hour": {
"type": "integer",
"description": "Earliest hour of the day to consider (default 8 for 08:00).",
"default": 8,
},
"day_end_hour": {
"type": "integer",
"description": "Latest hour of the day to consider (default 20 for 20:00).",
"default": 20,
},
},
},
},
@@ -827,7 +1282,7 @@ def run_mcp_server():
"capabilities": {"tools": {}},
"serverInfo": {
"name": "google-calendar-mcp",
"version": "1.1.0",
"version": "1.2.0",
},
},
}
@@ -858,6 +1313,8 @@ def run_mcp_server():
time_min=args.get("time_min"),
time_max=args.get("time_max"),
query=args.get("query"),
category=args.get("category"),
color_id=args.get("color_id"),
max_results=args.get("max_results", 25),
)
elif tool_name == "calendar_check_conflicts":
@@ -878,6 +1335,11 @@ def run_mcp_server():
all_day=args.get("all_day", False),
category=args.get("category"),
color_id=args.get("color_id"),
recurrence=args.get("recurrence"),
show_as_busy=args.get("show_as_busy"),
transparency=args.get("transparency"),
reminder_minutes=args.get("reminder_minutes"),
inclusive_end=args.get("inclusive_end", True),
check_conflicts_flag=args.get("check_conflicts", True),
)
elif tool_name == "calendar_update_event":
@@ -891,11 +1353,33 @@ def run_mcp_server():
location=args.get("location"),
category=args.get("category"),
color_id=args.get("color_id"),
recurrence=args.get("recurrence"),
show_as_busy=args.get("show_as_busy"),
transparency=args.get("transparency"),
reminder_minutes=args.get("reminder_minutes"),
inclusive_end=args.get("inclusive_end", True),
update_series=args.get("update_series", False),
)
elif tool_name == "calendar_delete_event":
data = delete_event(
calendar=args.get("calendar", "family"),
event_id=args.get("event_id", ""),
delete_series=args.get("delete_series", False),
)
elif tool_name == "calendar_move_event":
data = move_event(
event_id=args.get("event_id", ""),
source_calendar=args.get("source_calendar", "family"),
destination_calendar=args.get("destination_calendar", "regensburg"),
)
elif tool_name == "calendar_find_free_slots":
data = find_free_slots(
calendar=args.get("calendar", "family"),
time_min=args.get("time_min"),
time_max=args.get("time_max"),
duration_minutes=args.get("duration_minutes", 60),
day_start_hour=args.get("day_start_hour", 8),
day_end_hour=args.get("day_end_hour", 20),
)
elif tool_name == "calendar_get_event":
data = get_event(
@@ -953,6 +1437,8 @@ def run_cli():
sp_list.add_argument("--min", dest="time_min", help="ISO start time")
sp_list.add_argument("--max", dest="time_max", help="ISO end time")
sp_list.add_argument("--query", "-q", help="Search text query")
sp_list.add_argument("--category", help="Category filter")
sp_list.add_argument("--color", dest="color_id", help="Color ID filter")
sp_list.add_argument("--limit", "-n", type=int, default=25, help="Max results")
# Conflicts
@@ -962,6 +1448,13 @@ def run_cli():
sp_conf.add_argument("--calendar", "-c", default="family")
sp_conf.add_argument("--buffer", type=int, default=15, help="Buffer minutes")
# Free slots
sp_slots = subparsers.add_parser("free-slots", help="Find free time windows")
sp_slots.add_argument("--calendar", "-c", default="family", help="'family', 'regensburg', or 'both'")
sp_slots.add_argument("--min", dest="time_min", help="ISO start time")
sp_slots.add_argument("--max", dest="time_max", help="ISO end time")
sp_slots.add_argument("--duration", type=int, default=60, help="Required duration in minutes")
# Create
sp_create = subparsers.add_parser("create", help="Create event")
sp_create.add_argument("summary", help="Event summary/title")
@@ -969,14 +1462,24 @@ def run_cli():
sp_create.add_argument("--end", dest="end_time", help="End time")
sp_create.add_argument("--calendar", "-c", default="family", help="'family' or 'regensburg'")
sp_create.add_argument("--category", help="Category: arzt, mila, moritz, amelie, familie, haushalt")
sp_create.add_argument("--recurrence", "-r", help="RRULE recurrence (e.g. 'RRULE:FREQ=WEEKLY;BYDAY=SA' or 'weekly')")
sp_create.add_argument("--free", "--transparent", dest="transparent", action="store_true", help="Mark event as free/transparent")
sp_create.add_argument("--reminder", dest="reminder_minutes", type=int, action="append", help="Reminder in minutes before event")
sp_create.add_argument("--description", "-d", help="Description")
sp_create.add_argument("--location", "-l", help="Location")
sp_create.add_argument("--all-day", action="store_true", help="All day event")
# Move
sp_move = subparsers.add_parser("move", help="Move event between calendars")
sp_move.add_argument("event_id", help="Event ID")
sp_move.add_argument("--from", dest="source_calendar", default="family", help="Source calendar")
sp_move.add_argument("--to", dest="destination_calendar", default="regensburg", help="Destination calendar")
# Delete
sp_del = subparsers.add_parser("delete", help="Delete event")
sp_del.add_argument("event_id", help="Event ID")
sp_del.add_argument("--calendar", "-c", default="family", help="'family' or 'regensburg'")
sp_del.add_argument("--series", action="store_true", help="Delete entire recurring series")
args = parser.parse_args()
@@ -989,12 +1492,21 @@ def run_cli():
end_time=args.end_time,
buffer_minutes=args.buffer,
), indent=2, ensure_ascii=False))
elif args.command == "free-slots":
print(json.dumps(find_free_slots(
calendar=args.calendar,
time_min=args.time_min,
time_max=args.time_max,
duration_minutes=args.duration,
), indent=2, ensure_ascii=False))
elif args.command == "list":
print(json.dumps(list_events(
calendar=args.calendar,
time_min=args.time_min,
time_max=args.time_max,
query=args.query,
category=args.category,
color_id=args.color_id,
max_results=args.limit,
), indent=2, ensure_ascii=False))
elif args.command == "create":
@@ -1004,14 +1516,24 @@ def run_cli():
start_time=args.start_time,
end_time=args.end_time,
category=args.category,
recurrence=args.recurrence,
show_as_busy=not args.transparent,
reminder_minutes=args.reminder_minutes,
description=args.description,
location=args.location,
all_day=args.all_day,
), indent=2, ensure_ascii=False))
elif args.command == "move":
print(json.dumps(move_event(
event_id=args.event_id,
source_calendar=args.source_calendar,
destination_calendar=args.destination_calendar,
), indent=2, ensure_ascii=False))
elif args.command == "delete":
print(json.dumps(delete_event(
calendar=args.calendar,
event_id=args.event_id,
delete_series=args.series,
), indent=2, ensure_ascii=False))
else:
parser.print_help()