Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:49:50 +08:00
commit adc4b2be25
147 changed files with 24716 additions and 0 deletions

View File

@@ -0,0 +1,804 @@
"""
Google Calendar MCP client implementation using mxcp OAuth authentication.
This module provides Google Calendar API integration with:
- OAuth 2.0 authentication via mxcp framework
- Thread-safe client caching for performance
- Simplified time handling for LLM consumption
- Comprehensive error handling and user-friendly messages
- Full type safety with Pydantic models
"""
# Required for union syntax (|) in type annotations with runtime objects like threading.Lock
# Without this, Python tries to evaluate "threading.Lock | None" at runtime, which fails
from __future__ import annotations
import logging
import threading
from datetime import date, datetime, timezone
from functools import wraps
from typing import Any
from google.auth.exceptions import RefreshError
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build # type: ignore[import-untyped]
from googleapiclient.errors import HttpError # type: ignore[import-untyped]
from mxcp.runtime import on_init, on_shutdown
from mxcp.sdk.auth.context import get_user_context
# =============================================================================
# TIME CONVERSION UTILITIES
# =============================================================================
def _datetime_to_google_time(
dt: datetime, all_day: bool = False, time_zone: str | None = None
) -> dict[str, Any]:
"""
Convert datetime object to Google Calendar API time format.
Args:
dt: Python datetime object (should be timezone-aware)
all_day: Whether this represents an all-day event
time_zone: Optional timezone override
Returns:
Google API time object: {"dateTime": "...", "timeZone": "..."}
or {"date": "YYYY-MM-DD"} for all-day events
"""
if all_day:
# For all-day events, use date format
return {"date": dt.date().isoformat()}
else:
# For timed events, use dateTime format
time_obj = {"dateTime": dt.isoformat()}
# Add timezone if specified or if datetime has timezone info
if time_zone:
time_obj["timeZone"] = time_zone
elif dt.tzinfo:
# Extract timezone name from datetime object if possible
tz_name = getattr(dt.tzinfo, "zone", None) or str(dt.tzinfo)
if tz_name != "UTC" and "+" not in tz_name and "-" not in tz_name:
time_obj["timeZone"] = tz_name
else:
# If no timezone info is available, try to get user's timezone
try:
user_timezone = _get_user_timezone()
logger.warning(
f"Datetime object has no timezone info, using user timezone: {user_timezone}"
)
time_obj["timeZone"] = user_timezone
except ValueError as e:
raise ValueError(
f"Datetime object has no timezone information and cannot determine timezone from calendar. "
f"Please either: 1) Use timezone-aware datetime objects, or 2) Specify the time_zone parameter. "
f"Original error: {e}"
) from e
return time_obj
def _get_user_timezone() -> str:
"""Get the user's timezone from their primary calendar.
This function only tries to get the timezone from the user's primary calendar.
If that fails, it raises an exception to force explicit timezone specification.
Results are cached to avoid repeated lookups.
Returns:
IANA timezone identifier from user's primary calendar
Raises:
ValueError: If timezone cannot be determined from primary calendar
"""
global _user_timezone_cache, _timezone_cache_lock
# Check cache first
if _timezone_cache_lock and _user_timezone_cache:
with _timezone_cache_lock:
if _user_timezone_cache:
return _user_timezone_cache
try:
# Get timezone from user's primary calendar
calendar_info = get_calendar("primary")
if calendar_info and calendar_info.get("timeZone"):
user_timezone: str = calendar_info["timeZone"]
logger.debug(f"Using timezone from primary calendar: {user_timezone}")
# Cache the result
if _timezone_cache_lock:
with _timezone_cache_lock:
_user_timezone_cache = user_timezone
return user_timezone
else:
raise ValueError("Primary calendar does not have timezone information")
except Exception as e:
logger.debug(f"Could not get timezone from primary calendar: {e}")
raise ValueError(
"Cannot determine timezone from primary calendar. "
"Please specify the time_zone parameter explicitly in your function call."
) from e
def _google_time_to_datetime(google_time: dict[str, Any]) -> tuple[datetime, bool]:
"""
Convert Google Calendar API time format to datetime object.
Args:
google_time: Google API time object
Returns:
Tuple of (datetime_object, is_all_day)
"""
if "date" in google_time:
# All-day event - convert date to datetime at midnight UTC
date_obj = date.fromisoformat(google_time["date"])
dt = datetime.combine(date_obj, datetime.min.time(), tzinfo=timezone.utc)
return dt, True
elif "dateTime" in google_time:
# Timed event - parse ISO datetime
dt = datetime.fromisoformat(google_time["dateTime"])
return dt, False
else:
raise ValueError(f"Invalid Google time format: {google_time}")
def _convert_event_to_simplified(google_event: dict[str, Any]) -> dict[str, Any]:
"""
Convert Google Calendar API event to our simplified EventInfo format.
Args:
google_event: Raw event from Google Calendar API
Returns:
Event in simplified format matching EventInfo model
"""
# Convert start/end times
start_dt, start_all_day = _google_time_to_datetime(google_event["start"])
end_dt, end_all_day = _google_time_to_datetime(google_event["end"])
# Ensure both times have same all-day status
all_day = start_all_day and end_all_day
simplified_event = {
"id": google_event["id"],
"summary": google_event.get("summary", ""),
"description": google_event.get("description"),
"location": google_event.get("location"),
"start_time": start_dt,
"end_time": end_dt,
"all_day": all_day,
"time_zone": google_event.get("start", {}).get("timeZone"),
"status": google_event.get("status", "confirmed"),
"htmlLink": google_event.get("htmlLink", ""),
"created": datetime.fromisoformat(google_event["created"].replace("Z", "+00:00")),
"updated": datetime.fromisoformat(google_event["updated"].replace("Z", "+00:00")),
"calendar_id": google_event.get("calendarId", "unknown"), # Added by our code
"etag": google_event.get("etag"),
}
# Convert attendees if present
if "attendees" in google_event:
simplified_event["attendees"] = [
{
"email": att["email"],
"displayName": att.get("displayName"),
"responseStatus": att.get("responseStatus", "needsAction"),
"optional": att.get("optional", False),
"resource": att.get("resource", False),
"comment": att.get("comment"),
"additionalGuests": att.get("additionalGuests", 0),
}
for att in google_event["attendees"]
]
# Convert creator/organizer if present
for role in ["creator", "organizer"]:
if role in google_event:
simplified_event[role] = {
"email": google_event[role].get("email"),
"displayName": google_event[role].get("displayName"),
"self": google_event[role].get("self", False),
}
# Handle recurrence rules
if "recurrence" in google_event:
simplified_event["recurrence"] = google_event["recurrence"]
# Handle reminders
if "reminders" in google_event:
reminders = google_event["reminders"]
simplified_event["reminders"] = {
"useDefault": reminders.get("useDefault", True),
"overrides": reminders.get("overrides"),
}
# Handle transparency and visibility
simplified_event["transparency"] = google_event.get("transparency", "opaque")
simplified_event["visibility"] = google_event.get("visibility", "default")
return simplified_event
def _convert_simplified_to_google_event(simplified_event: dict[str, Any]) -> dict[str, Any]:
"""
Convert simplified event format to Google Calendar API format.
Args:
simplified_event: Event in our simplified format
Returns:
Event in Google Calendar API format
"""
google_event = {
"summary": simplified_event["summary"],
"start": _datetime_to_google_time(
simplified_event["start_time"],
simplified_event.get("all_day", False),
simplified_event.get("time_zone"),
),
"end": _datetime_to_google_time(
simplified_event["end_time"],
simplified_event.get("all_day", False),
simplified_event.get("time_zone"),
),
}
# Add optional fields
optional_fields = ["description", "location"]
for field in optional_fields:
if field in simplified_event and simplified_event[field] is not None:
google_event[field] = simplified_event[field]
# Convert attendees
if "attendees" in simplified_event and simplified_event["attendees"]:
google_event["attendees"] = [
(
{"email": str(email)}
if isinstance(email, str)
else {
"email": (
str(email) if isinstance(email, str) else str(attendee.get("email", ""))
),
"displayName": attendee.get("displayName"),
"optional": attendee.get("optional", False),
"resource": attendee.get("resource", False),
"comment": attendee.get("comment"),
"additionalGuests": attendee.get("additionalGuests", 0),
}
)
for email in simplified_event["attendees"]
for attendee in [email if isinstance(email, dict) else {"email": email}]
]
# Add transparency and visibility if specified
if "transparency" in simplified_event:
google_event["transparency"] = simplified_event["transparency"]
if "visibility" in simplified_event:
google_event["visibility"] = simplified_event["visibility"]
if "recurrence" in simplified_event:
google_event["recurrence"] = simplified_event["recurrence"]
# Add reminders if specified
if "reminders" in simplified_event and simplified_event["reminders"]:
google_event["reminders"] = simplified_event["reminders"]
return google_event
# =============================================================================
# THREAD-SAFE CLIENT CACHING
# =============================================================================
# Thread-safe cache for Google Calendar service clients
_client_cache: dict[str, Resource] | None = None
_cache_lock: threading.Lock | None = None
# Global timezone cache to avoid repeated lookups
_user_timezone_cache: str | None = None
_timezone_cache_lock: threading.Lock | None = None
@on_init
def init_client_cache() -> None:
"""Initialize the Google Calendar client cache and timezone cache."""
global _client_cache, _cache_lock, _timezone_cache_lock
_client_cache = {}
_cache_lock = threading.Lock()
_timezone_cache_lock = threading.Lock()
@on_shutdown
def clear_client_cache() -> None:
"""Clear the Google Calendar client cache and timezone cache."""
global _client_cache, _cache_lock, _user_timezone_cache, _timezone_cache_lock
_client_cache = None
_cache_lock = None
_user_timezone_cache = None
_timezone_cache_lock = None
def _get_cache_key(context: Any) -> str | None:
"""Generate cache key based on user context."""
if not context:
return None
# Use user ID as cache key for per-user client isolation
user_id = getattr(context, "user_id", None) or getattr(context, "id", None)
if user_id:
return f"gcal:{user_id}"
return None
def _get_google_credentials() -> Credentials:
"""Get Google OAuth credentials from mxcp user context."""
context = get_user_context()
if not context or not context.external_token:
raise ValueError("No user context available. User must be authenticated.")
# Create Google credentials object with OAuth token
credentials = Credentials(token=context.external_token) # type: ignore[no-untyped-call]
return credentials
# =============================================================================
# LOGGING & ERROR HANDLING
# =============================================================================
# Set up comprehensive logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def with_session_retry(func: Any) -> Any:
"""
Decorator for handling OAuth token refresh and API errors with user-friendly messages.
Wraps functions to automatically handle:
- OAuth token refresh failures (RefreshError)
- Google API HTTP errors with specific status codes
- Client cache invalidation on auth failures
- Comprehensive error logging for debugging
"""
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
logger.info(f"Executing {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
logger.info(f"Successfully completed {func.__name__}")
return result
except RefreshError as e:
# OAuth token has expired and cannot be refreshed
logger.warning(f"OAuth token refresh failed in {func.__name__}: {e}")
clear_client_cache() # Clear cache to force re-authentication
error_msg = (
"Your Google Calendar access has expired. Please re-authenticate to continue."
)
logger.error(f"Authentication error: {error_msg}")
raise ValueError(error_msg) from e
except HttpError as e:
# Handle specific Google API errors with detailed logging
status = e.resp.status
error_details = str(e)
logger.error(
f"Google API HttpError in {func.__name__}: status={status}, details={error_details}"
)
# Clear cache on authentication errors
if status in [401, 403]:
context = get_user_context()
cache_key = _get_cache_key(context)
if cache_key and _cache_lock and _client_cache:
with _cache_lock:
_client_cache.pop(cache_key, None)
# Just forward the original Google API error - it's already clear and actionable
raise ValueError(f"Google Calendar API error: {error_details}") from e
# ValidationError removed since we no longer use Pydantic models
except ValueError as e:
# Re-raise ValueError (these are user-friendly messages)
logger.warning(f"User error in {func.__name__}: {e}")
raise
except Exception as e:
# Catch-all for unexpected errors
logger.error(
f"Unexpected error in {func.__name__}: {type(e).__name__}: {e}", exc_info=True
)
raise ValueError(f"An unexpected error occurred: {str(e)}") from e
return wrapper
def _get_google_calendar_client() -> Resource:
"""
Get cached Google Calendar API client or create new one with OAuth authentication.
Uses per-user caching for performance and proper multi-user isolation.
"""
try:
# Get authenticated user context
context = get_user_context()
if not context:
raise ValueError("No user context available. User must be authenticated.")
# Check cache first
cache_key = _get_cache_key(context)
if cache_key and _cache_lock and _client_cache:
with _cache_lock:
if cache_key in _client_cache:
logging.info("Using cached Google Calendar client")
return _client_cache[cache_key]
logging.info("Creating new Google Calendar client")
# Create new authenticated client
credentials = _get_google_credentials()
service = build(
serviceName="calendar",
version="v3",
credentials=credentials,
cache_discovery=True, # Cache API discovery documents
num_retries=3, # Retry transient failures
)
# Cache the client for this user
if cache_key and _cache_lock and _client_cache:
with _cache_lock:
_client_cache[cache_key] = service
return service
except Exception as e:
raise ValueError(f"Failed to initialize Google Calendar client: {str(e)}") from e
# =============================================================================
# MCP TOOL FUNCTIONS
# =============================================================================
@with_session_retry
def whoami() -> dict[str, Any]:
"""
Get information about the currently authenticated user.
Returns:
UserInfo object with user profile data from Google OAuth
Note:
Uses OAuth profile information, no additional API calls required
"""
context = get_user_context()
if not context:
raise ValueError("No user context available. User must be authenticated.")
# Extract user information from OAuth profile
raw_profile = context.raw_profile or {}
# Return plain dictionary following standard MXCP pattern
return {
"id": raw_profile.get("sub") or raw_profile.get("id", "unknown"),
"email": raw_profile.get("email", "unknown@example.com"),
"name": raw_profile.get("name", "Unknown User"),
"given_name": raw_profile.get("given_name"),
"family_name": raw_profile.get("family_name"),
"picture": raw_profile.get("picture"),
"locale": raw_profile.get("locale"),
"verified_email": raw_profile.get("email_verified"),
}
@with_session_retry
def list_calendars(
show_hidden: bool = False,
show_deleted: bool = False,
max_results: int = 100,
min_access_role: str | None = None,
) -> list[dict[str, Any]]:
"""
List all calendars accessible to the authenticated user.
Args:
show_hidden: Include hidden calendars in results
show_deleted: Include deleted calendars in results
max_results: Maximum number of calendars to return (1-250)
min_access_role: Filter by minimum access level
Returns:
List of CalendarInfo objects with user's accessible calendars
Raises:
ValueError: If user is not authenticated or parameters are invalid
"""
service = _get_google_calendar_client()
# Build parameters
params: dict[str, Any] = {
"maxResults": min(max_results, 250),
"showHidden": show_hidden,
"showDeleted": show_deleted,
}
if min_access_role:
params["minAccessRole"] = min_access_role
# Execute API call
result = service.calendarList().list(**params).execute()
# Return plain dictionaries following standard MXCP pattern
calendars = []
for cal in result.get("items", []):
calendar_dict = {
"id": cal["id"],
"summary": cal.get("summary", ""),
"description": cal.get("description"),
"timeZone": cal.get("timeZone", "UTC"),
"accessRole": cal.get("accessRole", "reader"),
"primary": cal.get("primary", False),
"backgroundColor": cal.get("backgroundColor"),
"foregroundColor": cal.get("foregroundColor"),
"selected": cal.get("selected", False),
"hidden": cal.get("hidden", False),
"defaultReminders": cal.get("defaultReminders"),
}
calendars.append(calendar_dict)
return calendars
@with_session_retry
def get_calendar(calendar_id: str) -> dict[str, Any]:
"""
Get detailed information for a specific calendar.
Args:
calendar_id: Calendar identifier or 'primary' for main calendar
Returns:
CalendarInfo object with calendar details
Raises:
ValueError: If calendar_id is invalid or user lacks access
"""
service = _get_google_calendar_client()
try:
result = service.calendarList().get(calendarId=calendar_id).execute()
except HttpError as e:
if e.resp.status == 404:
raise ValueError(
f"Calendar '{calendar_id}' not found or you don't have access to it"
) from e
raise
# Return plain dictionary following standard MXCP pattern
return {
"id": result["id"],
"summary": result.get("summary", ""),
"description": result.get("description"),
"timeZone": result.get("timeZone", "UTC"),
"accessRole": result.get("accessRole", "reader"),
"primary": result.get("primary", False),
"backgroundColor": result.get("backgroundColor"),
"foregroundColor": result.get("foregroundColor"),
"selected": result.get("selected", False),
"hidden": result.get("hidden", False),
"defaultReminders": result.get("defaultReminders"),
}
@with_session_retry
def list_events(
calendar_id: str = "primary",
time_min: datetime | None = None,
time_max: datetime | None = None,
max_results: int = 250,
single_events: bool = True,
order_by: str = "startTime",
page_token: str | None = None,
) -> dict[str, Any]:
"""
List events from a specific calendar with optional time filtering.
Args:
calendar_id: Calendar to query ('primary' or specific calendar ID)
time_min: Lower bound for event start times (inclusive)
time_max: Upper bound for event start times (exclusive)
max_results: Maximum number of events to return (1-2500)
single_events: Whether to expand recurring events into instances
order_by: Sort order - 'startTime' or 'updated'
page_token: Token for pagination
Returns:
EventSearchResult with events and pagination info
"""
service = _get_google_calendar_client()
# Build parameters
params = {
"calendarId": calendar_id,
"maxResults": min(max_results, 2500),
"singleEvents": single_events,
"orderBy": order_by,
}
if time_min:
params["timeMin"] = time_min.isoformat()
if time_max:
params["timeMax"] = time_max.isoformat()
if page_token:
params["pageToken"] = page_token
# Execute API call
result = service.events().list(**params).execute()
# Convert events to simplified format
events = []
for event in result.get("items", []):
event["calendarId"] = calendar_id # Add calendar_id to event
simplified_event = _convert_event_to_simplified(event)
events.append(simplified_event)
# Return plain dictionary following standard MXCP pattern
return {
"events": events,
"next_page_token": result.get("nextPageToken"),
"total_results": len(events),
}
@with_session_retry
def get_event(calendar_id: str, event_id: str) -> dict[str, Any]:
"""
Retrieve detailed information for a specific event.
Args:
calendar_id: Calendar containing the event
event_id: Event identifier
Returns:
EventInfo object with complete event details
Raises:
ValueError: If event not found or user lacks access
"""
service = _get_google_calendar_client()
try:
result = service.events().get(calendarId=calendar_id, eventId=event_id).execute()
except HttpError as e:
if e.resp.status == 404:
raise ValueError(f"Event '{event_id}' not found in calendar '{calendar_id}'") from e
raise
# Add calendar_id to event
result["calendarId"] = calendar_id
simplified_event = _convert_event_to_simplified(result)
return simplified_event
@with_session_retry
def search_events(
q: str,
calendar_id: str = "primary",
time_min: datetime | None = None,
time_max: datetime | None = None,
max_results: int = 250,
page_token: str | None = None,
) -> dict[str, Any]:
"""
Search for events matching a text query.
Args:
q: Free text search query (searches title, description, location, attendees)
calendar_id: Calendar to search ('primary' or specific calendar ID)
time_min: Earliest event start time to include
time_max: Latest event start time to include
max_results: Maximum number of events to return
page_token: Token for pagination
Returns:
EventSearchResult with matching events and pagination info
"""
service = _get_google_calendar_client()
# Build parameters
params = {
"calendarId": calendar_id,
"q": q,
"maxResults": min(max_results, 2500),
"singleEvents": True,
"orderBy": "startTime",
}
if time_min:
params["timeMin"] = time_min.isoformat()
if time_max:
params["timeMax"] = time_max.isoformat()
if page_token:
params["pageToken"] = page_token
# Execute API call
result = service.events().list(**params).execute()
# Convert events to simplified format
events = []
for event in result.get("items", []):
event["calendarId"] = calendar_id # Add calendar_id to event
simplified_event = _convert_event_to_simplified(event)
events.append(simplified_event)
# Return plain dictionary following standard MXCP pattern
return {
"events": events,
"next_page_token": result.get("nextPageToken"),
"total_results": len(events),
}
@with_session_retry
def get_freebusy(calendar_ids: list[str], time_min: datetime, time_max: datetime) -> dict[str, Any]:
"""
Check free/busy status across multiple calendars.
Args:
calendar_ids: List of calendar IDs to check (use 'primary' for main calendar)
time_min: Start time for availability check
time_max: End time for availability check
Returns:
FreeBusyResponse with busy periods for each calendar
Note:
Useful for finding meeting slots and checking availability before scheduling
"""
if time_min >= time_max:
raise ValueError("time_max must be after time_min")
service = _get_google_calendar_client()
# Build request
request_body = {
"timeMin": time_min.isoformat(),
"timeMax": time_max.isoformat(),
"items": [{"id": cal_id} for cal_id in calendar_ids],
}
# Execute API call
result = service.freebusy().query(body=request_body).execute()
# Convert to plain dictionary format following standard MXCP pattern
calendars = []
for calendar_id in calendar_ids:
calendar_data = result.get("calendars", {}).get(calendar_id, {})
# Convert busy times to plain dictionaries
busy_times = []
for busy_period in calendar_data.get("busy", []):
busy_times.append(
{
"start": busy_period["start"],
"end": busy_period["end"],
}
)
calendars.append(
{"calendar_id": calendar_id, "busy": busy_times, "errors": calendar_data.get("errors")}
)
return {
"time_min": time_min.isoformat(),
"time_max": time_max.isoformat(),
"calendars": calendars,
}