Files

493 lines
17 KiB
Python
Raw Permalink Normal View History

2026-06-24 12:44:34 -07:00
"""FastMCP tool surface for the Google Calendar mock."""
import functools
import inspect
from fastmcp import FastMCP
from pydantic import EmailStr
from .models import (
AttendeeResponseStatus,
AvailabilityDurationMinutes,
BirthdayProperties,
CalendarAccountId,
CalendarAccountsState,
CalendarId,
CalendarPerson,
CalendarState,
CalendarSummary,
CalendarTimeZone,
EventAttendee,
EventClearField,
EventDateTime,
EventId,
EventReminders,
EventSource,
EventSummary,
EventTransparency,
EventType,
ExtendedProperties,
FocusTimeProperties,
GoogleCalendarState,
ListEventsMaxResults,
OutOfOfficeProperties,
RecurrenceRuleString,
Rfc3339OffsetDateTimeString,
SearchEventsMaxResults,
SearchEventsOrderBy,
SearchEventsResponse,
WorkingLocationProperties,
)
from .state import list_accounts as list_calendar_accounts
from .state import set_active_account, state_from_json, state_to_json, write_snapshots
from .tools import availability, calendars, events, search
mcp = FastMCP("google_calendar")
def _snapshot_on_write(fn):
"""Write configured state snapshots after successful write tools.
The wrapper is async so the registered tool is a coroutine: FastMCP then
validates and runs it on the event loop instead of a worker threadpool,
avoiding the pydantic-core concurrency panic. See ``async_tool_guard``.
"""
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
result = fn(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
write_snapshots()
return result
return wrapper
def _with_account(fn):
"""Decorator: extract account_id from tool calls and select that account."""
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
account_id = kwargs.pop("account_id", None)
if account_id is not None:
set_active_account(account_id)
result = fn(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
return result
return wrapper
@mcp.tool()
async def export_state() -> GoogleCalendarState:
"""Export the full google_calendar state as JSON.
Round-trips with import_state.
"""
state = state_to_json()
if "accounts" in state:
return CalendarAccountsState.model_validate(state)
return CalendarState.model_validate(state)
@mcp.tool()
@_snapshot_on_write
def import_state(state: GoogleCalendarState) -> dict:
"""Replace the google_calendar state with the provided JSON.
For synthetic-data injection and test setup. Round-trips with export_state.
"""
state_from_json(state)
return {"status": "success"}
@mcp.tool()
async def list_accounts() -> dict:
"""List available isolated Google Calendar accounts.
Multi-account worlds store each account as a fully separate calendar state.
Use the returned ``account_id`` with other tools to select the account to
read or mutate. Single-account worlds expose their only configured account.
"""
accounts = list_calendar_accounts()
return {"status": "success", "accounts": accounts, "count": len(accounts)}
@mcp.tool()
@_with_account
@_snapshot_on_write
def create_event(
summary: EventSummary,
start: EventDateTime,
end: EventDateTime,
description: str | None = None,
location: str | None = None,
calendar_id: CalendarId = "primary",
recurrence: list[RecurrenceRuleString] | None = None,
reminders: EventReminders | None = None,
attendees: list[EventAttendee] | None = None,
creator: CalendarPerson | None = None,
organizer: CalendarPerson | None = None,
extendedProperties: ExtendedProperties | None = None,
source: EventSource | None = None,
transparency: EventTransparency | None = None,
eventType: EventType = EventType.DEFAULT,
outOfOfficeProperties: OutOfOfficeProperties | None = None,
focusTimeProperties: FocusTimeProperties | None = None,
workingLocationProperties: WorkingLocationProperties | None = None,
birthdayProperties: BirthdayProperties | None = None,
account_id: CalendarAccountId | None = None,
) -> dict:
"""
Creates a new event in Google Calendar.
Args:
summary: Event title
start: Start time object with either 'dateTime' (ISO format, e.g. '2025-12-10T09:00:00-05:00')
for timed events, or 'date' (YYYY-MM-DD, e.g. '2025-12-10') for all-day events.
Optionally include 'timeZone' (e.g. 'America/New_York').
end: End time object with either 'dateTime' or 'date' (same format as start).
For all-day events, 'date' is exclusive (e.g. end '2025-12-11' means the event ends on 2025-12-10).
description: Event description
location: Event location
calendar_id: Calendar to create the event in (default 'primary')
recurrence: RRULE strings for recurring events (e.g., ['RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=10'])
reminders: Reminder overrides, e.g. {"useDefault": false, "overrides": [{"method": "popup", "minutes": 15}]}
attendees: List of attendees, e.g. [{"email": "alice@co.com", "displayName": "Alice"}]. responseStatus defaults to 'needsAction'.
transparency: Whether the event blocks availability. 'opaque' is busy; 'transparent' is free.
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
Returns:
The created event object with its ID
"""
return events.create_event(
summary=summary,
start=start,
end=end,
description=description,
location=location,
calendar_id=calendar_id,
recurrence=recurrence,
reminders=reminders,
attendees=attendees,
creator=creator,
organizer=organizer,
extendedProperties=extendedProperties,
source=source,
transparency=transparency,
eventType=eventType,
outOfOfficeProperties=outOfOfficeProperties,
focusTimeProperties=focusTimeProperties,
workingLocationProperties=workingLocationProperties,
birthdayProperties=birthdayProperties,
)
@mcp.tool()
@_with_account
def get_event(
eventId: EventId,
calendar_id: CalendarId = "primary",
account_id: CalendarAccountId | None = None,
) -> dict:
"""
Retrieves details of a specific event.
Args:
eventId: ID of the event to retrieve
calendar_id: Calendar to look in (default 'primary')
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
Returns:
The event object if found
"""
return events.get_event(eventId=eventId, calendar_id=calendar_id)
@mcp.tool()
@_with_account
@_snapshot_on_write
def update_event(
eventId: EventId,
summary: EventSummary | None = None,
start: EventDateTime | None = None,
end: EventDateTime | None = None,
description: str | None = None,
location: str | None = None,
calendar_id: CalendarId = "primary",
recurrence: list[RecurrenceRuleString] | None = None,
reminders: EventReminders | None = None,
attendees: list[EventAttendee] | None = None,
creator: CalendarPerson | None = None,
organizer: CalendarPerson | None = None,
extendedProperties: ExtendedProperties | None = None,
source: EventSource | None = None,
transparency: EventTransparency | None = None,
eventType: EventType | None = None,
outOfOfficeProperties: OutOfOfficeProperties | None = None,
focusTimeProperties: FocusTimeProperties | None = None,
workingLocationProperties: WorkingLocationProperties | None = None,
birthdayProperties: BirthdayProperties | None = None,
clear_fields: list[EventClearField] | None = None,
account_id: CalendarAccountId | None = None,
) -> dict:
"""
Updates an existing event.
Args:
eventId: ID of the event to update
summary: New event title
start: New start time object with either 'dateTime' (ISO format) for timed events,
or 'date' (YYYY-MM-DD) for all-day events. Optionally include 'timeZone'.
end: New end time object with either 'dateTime' or 'date' (same format as start).
description: New event description
location: New event location
calendar_id: Calendar containing the event (default 'primary')
recurrence: RRULE strings for recurring events (pass empty list to remove recurrence)
reminders: Reminder overrides (pass {"useDefault": true} to reset to defaults)
attendees: Replace attendee list (pass empty list to remove all attendees)
transparency: Whether the event blocks availability. 'opaque' is busy; 'transparent' is free.
eventType: Event type to set. Switching types clears properties that only belong to the old type.
clear_fields: Optional event fields to remove, e.g. ['source', 'transparency'].
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
Returns:
The updated event object
"""
return events.update_event(
eventId=eventId,
summary=summary,
start=start,
end=end,
description=description,
location=location,
calendar_id=calendar_id,
recurrence=recurrence,
reminders=reminders,
attendees=attendees,
creator=creator,
organizer=organizer,
extendedProperties=extendedProperties,
source=source,
transparency=transparency,
eventType=eventType,
outOfOfficeProperties=outOfOfficeProperties,
focusTimeProperties=focusTimeProperties,
workingLocationProperties=workingLocationProperties,
birthdayProperties=birthdayProperties,
clear_fields=clear_fields,
)
@mcp.tool()
@_with_account
@_snapshot_on_write
def delete_event(
eventId: EventId,
calendar_id: CalendarId = "primary",
account_id: CalendarAccountId | None = None,
) -> dict:
"""
Deletes an event from the calendar.
Args:
eventId: ID of the event to delete
calendar_id: Calendar containing the event (default 'primary')
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
Returns:
Confirmation of deletion
"""
return events.delete_event(eventId=eventId, calendar_id=calendar_id)
@mcp.tool()
@_with_account
@_snapshot_on_write
def respond_to_event(
eventId: EventId,
email: EmailStr,
response: AttendeeResponseStatus,
calendar_id: CalendarId = "primary",
account_id: CalendarAccountId | None = None,
) -> dict:
"""
Update an attendee's RSVP response on an event.
Args:
eventId: ID of the event
email: Email address of the attendee responding
response: Response status — 'accepted', 'declined', 'tentative', or 'needsAction'
calendar_id: Calendar containing the event (default 'primary')
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
Returns:
The updated event with attendee responses
"""
return events.respond_to_event(eventId=eventId, email=email, response=response, calendar_id=calendar_id)
@mcp.tool()
@_with_account
@_snapshot_on_write
def create_calendar(
summary: CalendarSummary,
description: str | None = None,
timeZone: CalendarTimeZone | None = None,
account_id: CalendarAccountId | None = None,
) -> dict:
"""
Creates a new calendar (e.g., 'Personal', 'Team Meetings', 'On-Call').
Args:
summary: Calendar name
description: Calendar description
timeZone: IANA time zone for the calendar. Defaults to the primary calendar time zone.
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
Returns:
The created calendar object
"""
return calendars.create_calendar(summary=summary, description=description, timeZone=timeZone)
@mcp.tool()
@_with_account
def list_calendars(account_id: CalendarAccountId | None = None) -> dict:
"""
Lists all calendars. Always includes the 'primary' calendar plus any
additional calendars that have been created.
Returns:
List of calendars with their IDs, names, and event counts
"""
return calendars.list_calendars()
@mcp.tool()
@_with_account
def list_events(
timeMin: Rfc3339OffsetDateTimeString,
timeMax: Rfc3339OffsetDateTimeString,
maxResults: ListEventsMaxResults | None = None,
orderBy: SearchEventsOrderBy | None = None,
calendar_id: CalendarId | None = None,
account_id: CalendarAccountId | None = None,
) -> dict:
"""
Lists events within a specified time range.
Args:
timeMin: Lower bound (exclusive) for an event's end time (ISO format).
Events that end after this time are included.
timeMax: Upper bound (exclusive) for an event's start time (ISO format).
Events that start before this time are included.
maxResults: Maximum number of events to return
orderBy: Sort order ('startTime' or 'updated')
calendar_id: Calendar to list events from. If omitted, lists events from all calendars.
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
Returns:
List of events that overlap the given time range
"""
return search.list_events(
timeMin=timeMin,
timeMax=timeMax,
maxResults=maxResults,
orderBy=orderBy,
calendar_id=calendar_id,
)
@mcp.tool()
@_with_account
def search_events(
query: str = "",
timeMin: Rfc3339OffsetDateTimeString | None = None,
timeMax: Rfc3339OffsetDateTimeString | None = None,
calendar_id: CalendarId | None = None,
attendee_email: EmailStr | None = None,
response_status: AttendeeResponseStatus | None = None,
organizer_email: EmailStr | None = None,
creator_email: EmailStr | None = None,
maxResults: SearchEventsMaxResults | None = None,
orderBy: SearchEventsOrderBy | None = SearchEventsOrderBy.START_TIME,
account_id: CalendarAccountId | None = None,
) -> SearchEventsResponse:
"""
Search calendar events by keyword, phrase, attendee, location, or calendar.
Args:
query: Search query. Bare words are ANDed across summary, description,
location, attendees, and calendar names. Quoted phrases require
exact adjacency. May be empty when attendee_email is provided.
timeMin: Optional lower bound (exclusive) for an event's end time.
timeMax: Optional upper bound (exclusive) for an event's start time.
calendar_id: Calendar to search. If omitted, searches all calendars.
attendee_email: Optional attendee email or display-name substring.
response_status: Optional attendee RSVP filter ('accepted', 'declined',
'tentative', or 'needsAction'). When attendee_email is
provided, applies to that attendee; otherwise matches
any attendee with that status.
organizer_email: Optional organizer email substring.
creator_email: Optional creator email substring.
maxResults: Maximum number of matching events to return. 0 returns no results.
orderBy: Sort order ('startTime', 'updated', or None).
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
Returns:
Matching events with calendar_id annotated on each result.
"""
return search.search_events(
query=query,
timeMin=timeMin,
timeMax=timeMax,
calendar_id=calendar_id,
attendee_email=attendee_email,
response_status=response_status,
organizer_email=organizer_email,
creator_email=creator_email,
maxResults=maxResults,
orderBy=orderBy,
)
@mcp.tool()
@_with_account
def check_availability(
timeMin: Rfc3339OffsetDateTimeString,
timeMax: Rfc3339OffsetDateTimeString,
duration_minutes: AvailabilityDurationMinutes = 30,
calendar_id: CalendarId | None = None,
attendee_emails: list[EmailStr] | None = None,
account_id: CalendarAccountId | None = None,
) -> dict:
"""
Check availability within a time range. Returns busy periods and free slots
of at least the requested duration.
When attendee_emails is provided, only events where at least one of those
people is an attendee (and hasn't declined) are considered busy. This lets
you find times that work for specific people.
Args:
timeMin: Start of the range to check (ISO format)
timeMax: End of the range to check (ISO format)
duration_minutes: Minimum free slot duration in minutes (default 30)
calendar_id: Calendar to check. If omitted, checks all calendars.
attendee_emails: Only consider events involving these attendees (optional)
account_id: Calendar account to use in multi-account worlds. Defaults to the active account.
Returns:
Busy periods and available free slots within the range
"""
return availability.check_availability(
timeMin=timeMin,
timeMax=timeMax,
duration_minutes=duration_minutes,
calendar_id=calendar_id,
attendee_emails=attendee_emails,
)