Skip to content

Instantly share code, notes, and snippets.

@aartraju
Last active June 1, 2026 14:58
Show Gist options
  • Select an option

  • Save aartraju/cedca245d76894ebe44ba2322ab15682 to your computer and use it in GitHub Desktop.

Select an option

Save aartraju/cedca245d76894ebe44ba2322ab15682 to your computer and use it in GitHub Desktop.
How to sync Outlook ↔ Gmail calendars using AI + MCP (Amazon Quick)

How I Synced My Work Calendar (Outlook) with My Personal Calendar (Gmail) Using AI + MCP

The Problem

Two calendars. Work life in Outlook, personal life in Gmail. Neither knows the other exists.

Real scenarios that kept happening:

  • I'd block "Business Travel" on my work calendar (3 days in NYC) but my husband had no idea I'd be gone until I told him
  • I'd schedule a doctor appointment for my kiddo on Gmail, then forget to block my work calendar. colleagues would book meetings over it
  • Weekend-only family trips didn't need a work block, but a Friday-through-Sunday trip did. I had to think about this manually every time
  • Single-evening events (team dinners, after-work events tagged for sync) landed on Gmail as all-day blocks instead of at the right time

What I wanted:

  • When I add work travel to Outlook → automatically show it on our family Gmail calendar (and notify my husband)
  • When I add a doctor appointment on Gmail → automatically block my work calendar as Out of Office
  • Smart rules: don't sync routine daily blocks, skip weekend-only trips, only sync what matters
  • Timed events stay timed. A dinner at 5:30 PM should show at 5:30 PM, not as an all-day event

What I Built

A daily sync agent that runs at 4 PM and keeps both calendars in sync, using:

  • Amazon Quick (AI assistant with calendar access)
  • A lightweight MCP server (Python script on my Mac that bridges to Google Calendar via OAuth)
  • A scheduled agent with custom sync rules

Architecture

Outlook (Work Calendar)
    ↕  Amazon Quick reads/writes via built-in Outlook connector
Amazon Quick (AI Agent)
    ↕  Calls MCP tools over stdio
Google Calendar MCP Server (Python on my Mac)
    ↕  Google Calendar REST API (OAuth 2.0)
Gmail Calendar (Personal)

Prerequisites

  • Amazon Quick with Outlook calendar connected
  • A Google account with Google Calendar
  • Python 3.10+ on your machine
  • pip install google-auth google-auth-oauthlib google-api-python-client

Step 1: Create a Google Cloud Project + OAuth Credentials

  1. Go to console.cloud.google.com
  2. Create a new project (e.g., "Calendar Sync")
  3. Enable the Google Calendar API (APIs & Services → Library → search "Google Calendar API" → Enable)
  4. Configure the OAuth consent screen:
    • Select "External"
    • App name: "Calendar Sync"
    • Add your email as a test user
  5. Create OAuth credentials:
    • APIs & Services → Credentials → Create Credentials → OAuth client ID
    • Application type: Desktop app
    • Download the JSON file
  6. Save it to ~/.config/caldav/client_secret.json:
mkdir -p ~/.config/caldav
mv ~/Downloads/client_secret_*.json ~/.config/caldav/client_secret.json

Cost: Free. Google Calendar API has a massive free tier (1M queries/day). Personal sync uses ~10 calls/day.

Step 2: Set Up the MCP Server

Save the following Python script to ~/mcp-servers/gcal_mcp_server.py:

#!/usr/bin/env python3
"""
Google Calendar MCP Server (OAuth 2.0)
Bridges AI assistants to Gmail Calendar via Google Calendar REST API.
"""

import json
import sys
from datetime import datetime, timedelta
from pathlib import Path

from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build

CONFIG_DIR = Path.home() / ".config" / "caldav"
CLIENT_SECRET_PATH = CONFIG_DIR / "client_secret.json"
TOKEN_PATH = CONFIG_DIR / "token.json"
SCOPES = ["https://www.googleapis.com/auth/calendar"]


def get_calendar_service():
    creds = None
    if TOKEN_PATH.exists():
        creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file(str(CLIENT_SECRET_PATH), SCOPES)
            creds = flow.run_local_server(port=0)
        with open(TOKEN_PATH, "w") as f:
            f.write(creds.to_json())
    return build("calendar", "v3", credentials=creds)


def list_events(start_date, end_date):
    service = get_calendar_service()
    events = service.events().list(
        calendarId="primary",
        timeMin=f"{start_date}T00:00:00Z",
        timeMax=f"{end_date}T23:59:59Z",
        singleEvents=True,
        orderBy="startTime",
    ).execute().get("items", [])
    return [{"summary": e.get("summary", ""), "start": e["start"].get("dateTime", e["start"].get("date")),
             "end": e["end"].get("dateTime", e["end"].get("date")), "id": e.get("id"),
             "all_day": "date" in e["start"]} for e in events]


def create_event(summary, start_date, end_date, description="",
                 start_time="", end_time="", all_day=True, attendees=None):
    """
    Create a calendar event. Supports all-day and timed events.

    For timed events: pass start_time and end_time in HH:MM 24h format.
    This overrides the all_day flag.
    """
    service = get_calendar_service()
    is_timed = bool(start_time and end_time)

    if is_timed:
        body = {
            "summary": summary, "description": description, "status": "confirmed",
            "start": {"dateTime": f"{start_date}T{start_time}:00", "timeZone": "America/Los_Angeles"},
            "end": {"dateTime": f"{end_date}T{end_time}:00", "timeZone": "America/Los_Angeles"},
        }
    elif all_day:
        body = {
            "summary": summary, "description": description, "status": "confirmed",
            "start": {"date": start_date},
            "end": {"date": end_date},  # EXCLUSIVE for all-day
        }
    else:
        body = {
            "summary": summary, "description": description, "status": "confirmed",
            "start": {"dateTime": f"{start_date}T09:00:00", "timeZone": "America/Los_Angeles"},
            "end": {"dateTime": f"{end_date}T17:00:00", "timeZone": "America/Los_Angeles"},
        }

    if attendees:
        body["attendees"] = [{"email": email} for email in attendees]

    event = service.events().insert(calendarId="primary", body=body).execute()
    return {"success": True, "id": event.get("id"), "link": event.get("htmlLink")}


def delete_event(event_id):
    service = get_calendar_service()
    service.events().delete(calendarId="primary", eventId=event_id).execute()
    return {"success": True}


# --- MCP JSON-RPC Server (reads from stdin, writes to stdout) ---

TOOLS = {
    "caldav_list_events": {"description": "List Gmail calendar events in a date range.",
        "parameters": {"type": "object", "properties": {
            "start_date": {"type": "string", "description": "Start date (YYYY-MM-DD)"},
            "end_date": {"type": "string", "description": "End date (YYYY-MM-DD)"}},
            "required": ["start_date", "end_date"]}},
    "caldav_create_event": {
        "description": "Create event on Gmail calendar. Supports all-day (default) and timed events. "
                       "For timed events, pass start_time and end_time in HH:MM 24h format. "
                       "end_date is EXCLUSIVE for all-day events.",
        "parameters": {"type": "object", "properties": {
            "summary": {"type": "string", "description": "Event title"},
            "start_date": {"type": "string", "description": "Start date (YYYY-MM-DD)"},
            "end_date": {"type": "string", "description": "End date EXCLUSIVE (YYYY-MM-DD)"},
            "description": {"type": "string", "description": "Optional description", "default": ""},
            "start_time": {"type": "string", "description": "Start time HH:MM 24h (e.g. '17:30'). Creates timed event.", "default": ""},
            "end_time": {"type": "string", "description": "End time HH:MM 24h (e.g. '19:30'). Required with start_time.", "default": ""},
            "all_day": {"type": "boolean", "description": "All-day event (default true)", "default": True},
            "attendees": {"type": "array", "items": {"type": "string"}, "description": "Email addresses to invite", "default": None}},
            "required": ["summary", "start_date", "end_date"]}},
    "caldav_delete_event": {"description": "Delete event from Gmail calendar by ID.",
        "parameters": {"type": "object", "properties": {"event_id": {"type": "string"}}, "required": ["event_id"]}},
}


def handle_request(request):
    method, params, req_id = request.get("method"), request.get("params", {}), request.get("id")
    if method == "initialize":
        return {"jsonrpc": "2.0", "id": req_id, "result": {"protocolVersion": "2024-11-05",
            "capabilities": {"tools": {}}, "serverInfo": {"name": "gcal-mcp", "version": "2.1.0"}}}
    elif method == "notifications/initialized":
        return None
    elif method == "tools/list":
        return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": [
            {"name": n, "description": s["description"], "inputSchema": s["parameters"]} for n, s in TOOLS.items()]}}
    elif method == "tools/call":
        name, args = params.get("name"), params.get("arguments", {})
        try:
            result = {"caldav_list_events": list_events, "caldav_create_event": create_event,
                      "caldav_delete_event": delete_event}[name](**args)
            return {"jsonrpc": "2.0", "id": req_id, "result": {"content": [{"type": "text", "text": json.dumps(result, default=str)}]}}
        except Exception as e:
            return {"jsonrpc": "2.0", "id": req_id, "result": {"content": [{"type": "text", "text": json.dumps({"error": str(e)})}], "isError": True}}
    return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Unknown: {method}"}}


if __name__ == "__main__":
    for line in sys.stdin:
        if not line.strip():
            continue
        try:
            resp = handle_request(json.loads(line))
            if resp:
                sys.stdout.write(json.dumps(resp) + "\n")
                sys.stdout.flush()
        except json.JSONDecodeError:
            continue

Step 3: Authorize (One Time)

Run this once to complete the OAuth flow:

python3 -c "
from google_auth_oauthlib.flow import InstalledAppFlow
from pathlib import Path

flow = InstalledAppFlow.from_client_secrets_file(
    str(Path.home() / '.config/caldav/client_secret.json'),
    ['https://www.googleapis.com/auth/calendar']
)
creds = flow.run_local_server(port=8080)

token_path = Path.home() / '.config/caldav/token.json'
with open(token_path, 'w') as f:
    f.write(creds.to_json())
print('Token saved!')
"

A browser window will open. Sign in, click "Advanced" → "Go to Calendar Sync (unsafe)" → "Continue". Token is saved. You won't need to do this again.

Step 4: Register in Amazon Quick

Settings → Capabilities → MCP Servers → Add:

  • Name: calendar_integ (or any name)
  • Command: python3
  • Args: /Users/yourname/mcp-servers/gcal_mcp_server.py

Toggle on. Should show "Connected."

Important: If you update the MCP server code (e.g., adding new parameters), you need to delete and re-add the connection in Quick for it to pick up the new tool schema. A simple toggle off/on may not refresh the schema cache.

Step 5: Create the Scheduled Sync Agent

In Amazon Quick, create a scheduled agent with:

  • Schedule: Daily at 4:00 PM (or whenever works for you)
  • Tool access: Outlook (read + create/delete with approval), Gmail MCP (full read/write)
  • Scan window: 4 weeks, using week-by-week views (keeps result sets small so the agent reliably catches category-tagged events)

My Sync Rules

Outlook → Gmail (work events → family calendar):

Trigger Action
Title contains "Aarthi: Business Travel" Sync + invite husband
Title contains "Aarthi: Team event" Sync + invite husband
Category = "Sync to Gmail" Sync + invite husband
Event spans 2+ days Sync + invite husband

Timed vs all-day: Multi-day events sync as all-day. Single-day events (tagged with "Sync to Gmail" category) sync as timed events preserving the original start/end time. A 5:30 PM dinner stays a 5:30 PM dinner.

Gmail → Outlook (personal → work calendar):

Trigger Action
Title contains "Kiddo doc" or "Kiddo doc appt" Block as OOF
Event spans 2+ days (with a weekday) Block as OOF

Never sync:

  • Recurring daily blocks (drop-off, pickup, commute). these live natively on both calendars
  • Weekend-only events (Sat-Sun trips)
  • Other people's OOO notifications
  • Workouts, birthdays, routine items

Deletion sync: If a source event is deleted, the synced copy is removed too.

Gotchas & Lessons Learned

  1. Google CalDAV requires OAuth 2.0. App passwords don't work for CalDAV/Calendar API. Don't waste time trying.
  2. Timezone matters. Always include -07:00 (or your offset) when creating timed events on Outlook. Bare datetimes get interpreted as UTC.
  3. Recurring events don't belong in sync. If an event repeats daily/weekly, create it natively on both calendars. Syncing individual occurrences creates approval fatigue.
  4. Deduplication is essential. Without it, the agent creates duplicate events on every run.
  5. Outlook's create/delete tools require user approval. You can't fully automate writes to Outlook. but it's just one tap per event.
  6. end_date is EXCLUSIVE in Google Calendar. A trip ending June 20 needs end_date = 2026-06-21.
  7. MCP schema caching. If you add new parameters to your MCP server (like start_time/end_time for timed events), Amazon Quick won't pick them up until you delete and re-add the MCP connection. A toggle off/on is not sufficient.
  8. Keep calendar scans small. Scanning a full month returns 50+ events. The AI agent can miss category-tagged events buried in large result sets. Scanning week-by-week (4 weeks total) keeps each batch manageable.

What It Looks Like in Practice

  • I add "Aarthi: Business Travel — NYC Summit" to Outlook → my husband gets a Google Calendar invite automatically
  • I tag a Wednesday dinner with "Sync to Gmail" category → it shows on Gmail at the correct time (not as an all-day block)
  • I add "Kiddo doc visit" to Gmail → my Outlook shows OOF for that time
  • I delete the doctor appointment from Gmail → the OOF block disappears from Outlook
  • Weekend family trips don't clutter my work calendar
  • I do nothing except add/remove events where I normally would. the sync just happens.

License

MIT. do whatever you want with this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment