Files
2026-07-10 15:02:09 +01:00

981 lines
36 KiB
Python
Executable File

#!/usr/bin/env python3
"""Google Calendar MCP server (JSON-RPC 2.0 over stdio).
Capabilities (callable as `mcp__gcal__<tool>`):
status — self-check: credentials, token refresh, API reachability
list_calendars — list calendars accessible to the user
list_events — chronological event listing with optional filters
get_event — read a single event by ID
create_event — create an event
update_event — patch fields of an existing event
delete_event — permanently delete an event
respond_to_event — set RSVP / attendance response
Credentials are read from ./secrets/google_creds.json by default.
Override with GOOGLE_CREDS_PATH env var.
Required OAuth scopes:
https://www.googleapis.com/auth/calendar
(or https://www.googleapis.com/auth/calendar.events for events-only)
Run scripts/gcal_oauth_setup.py to (re-)authenticate.
"""
from __future__ import annotations
import json
import os
import sys
import threading
import time
from datetime import datetime, timezone
from typing import Any, Callable
# Log to stderr so stdout stays clean for JSON-RPC.
def log(msg: str) -> None:
print(f"[gcal_mcp] {msg}", file=sys.stderr, flush=True)
# Protects all stdout writes (main thread + poll thread).
_stdout_lock = threading.Lock()
# ── Push notifications ─────────────────────────────────────────────────────────
def _emit_notification(method: str, params: dict) -> None:
"""Write a JSON-RPC notification (no id) to stdout."""
msg = json.dumps({"jsonrpc": "2.0", "method": method, "params": params})
with _stdout_lock:
sys.stdout.write(msg + "\n")
sys.stdout.flush()
# ISO-8601 UTC timestamp of when we last polled.
# We emit events whose `created` field is >= this value.
_last_poll_at: str | None = None
_poll_thread: threading.Thread | None = None
_POLL_INTERVAL_SECS = 300 # 5 minutes
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _start_polling() -> None:
"""Build service eagerly, record start time, launch poll thread."""
global _last_poll_at, _poll_thread
svc = _get_service()
if svc is None:
log("GCal push polling disabled: service not available.")
return
_last_poll_at = _utc_now_iso()
log(f"GCal polling started (tracking events created after {_last_poll_at}, interval={_POLL_INTERVAL_SECS}s).")
_poll_thread = threading.Thread(target=_poll_loop, daemon=True, name="gcal-poll")
_poll_thread.start()
def _poll_loop() -> None:
while True:
time.sleep(_POLL_INTERVAL_SECS)
_poll_once()
def _poll_once() -> None:
global _last_poll_at
svc = _get_service()
if svc is None or _last_poll_at is None:
return
since = _last_poll_at
_last_poll_at = _utc_now_iso() # advance cursor before the call (safe: we only advance)
try:
result = _call(lambda: svc.events().list(
calendarId="primary",
updatedMin=since,
singleEvents=True,
orderBy="updated",
maxResults=50,
).execute(), "Calendar")
except Exception as e:
log(f"GCal poll error: {_format_google_error(e, 'Calendar')}")
return
for ev in result.get("items", []):
# Emit only events that were newly *created* in this window (not just modified).
created = ev.get("created", "")
if created < since:
continue
start = ev.get("start") or {}
end = ev.get("end") or {}
_emit_notification("event/new_calendar_event", {
"event_id": ev.get("id"),
"summary": ev.get("summary", "(no title)"),
"start": start.get("dateTime") or start.get("date"),
"end": end.get("dateTime") or end.get("date"),
"location": ev.get("location"),
"description": (ev.get("description") or "")[:500],
"html_link": ev.get("htmlLink"),
"created": created,
})
log(f"Notification emitted: new calendar event {ev.get('id')!r}{ev.get('summary')!r}")
# ── Credentials / service ──────────────────────────────────────────────────────
_service = None
_creds = None
_creds_path: str | None = None
_init_error: str | None = None
def _persist_creds() -> None:
"""Write the current credentials back to disk (used after a token refresh)."""
if _creds is not None and _creds_path:
try:
with open(_creds_path, "w") as f:
f.write(_creds.to_json())
except Exception as e:
log(f"Could not persist refreshed credentials: {e}")
def _build_service() -> Any:
"""Build and return a Google Calendar service object, or None on failure."""
global _init_error, _creds, _creds_path
try:
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
except ImportError as e:
_init_error = f"Missing dependencies: {e}. Install google-api-python-client and google-auth."
log(_init_error)
return None
_creds_path = os.environ.get(
"GOOGLE_CREDS_PATH",
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "secrets", "google_creds.json"),
)
if not os.path.exists(_creds_path):
_init_error = (
f"Credentials file not found at {_creds_path}. "
"Run scripts/gcal_oauth_setup.py to authenticate, or set GOOGLE_CREDS_PATH."
)
log(_init_error)
return None
try:
creds = Credentials.from_authorized_user_file(_creds_path)
except Exception as e:
_init_error = f"Failed to load credentials from {_creds_path}: {e}"
log(_init_error)
return None
# Publish creds globally so _persist_creds / _call can see them.
_creds = creds
# Refresh expired token automatically at startup.
if creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
_persist_creds()
log("Token refreshed and saved.")
except Exception as e:
log(f"Token refresh failed: {e}")
try:
service = build("calendar", "v3", credentials=creds)
except Exception as e:
_init_error = f"Failed to build Calendar service: {e}"
log(_init_error)
return None
log(f"Calendar service built successfully (creds: {_creds_path})")
return service
def _get_service() -> Any:
global _service
if _service is None:
_service = _build_service()
return _service
# ── Error mapping & refresh-on-auth-error ──────────────────────────────────────
def _is_auth_error(e: Exception) -> bool:
"""True for 401 HttpError / RefreshError — candidates for a refresh+retry."""
try:
from googleapiclient.errors import HttpError
except ImportError:
return False
if isinstance(e, HttpError):
return getattr(e, "status_code", None) == 401
try:
from google.auth.exceptions import RefreshError
except ImportError:
return False
return isinstance(e, RefreshError)
def _call(fn: Callable[[], Any], api_label: str) -> Any:
"""Run a googleapiclient call with one refresh-on-auth-error retry.
If the access token expired mid-session the first call raises a 401 HttpError
or a RefreshError. We refresh once, persist the new token, and retry the call.
Anything else (or a second failure) is re-raised so the caller can format it
via _format_google_error.
"""
try:
return fn()
except Exception as e:
if not _is_auth_error(e) or _creds is None or not getattr(_creds, "refresh_token", None):
raise
try:
from google.auth.transport.requests import Request
_creds.refresh(Request())
_persist_creds()
log("Access token refreshed mid-session after auth error; retrying the call.")
except Exception as refresh_err:
log(f"Mid-session token refresh failed: {refresh_err}")
raise
return fn()
def _http_error_reason(e: Exception) -> str:
"""Best-effort short reason string from an HttpError (for 400/4xx detail)."""
return str(e).strip().replace("\n", " ")[:200]
def _format_google_error(e: Exception, api_label: str) -> str:
"""Map a googleapiclient / google-auth exception into an actionable Error: string."""
try:
from googleapiclient.errors import HttpError
except ImportError:
HttpError = None # type: ignore
try:
from google.auth.exceptions import RefreshError
except ImportError:
RefreshError = None # type: ignore
if RefreshError is not None and isinstance(e, RefreshError):
return (
f"Error: {api_label} API token refresh failed (the refresh token may have been revoked "
"or expired). Re-run scripts/gcal_oauth_setup.py to re-authenticate."
)
if HttpError is not None and isinstance(e, HttpError):
status = getattr(e, "status_code", None)
if status == 401:
return (
f"Error: {api_label} API rejected the access token (401). The OAuth token is invalid "
"or revoked. Re-run scripts/gcal_oauth_setup.py to re-authenticate."
)
if status == 403:
return (
f"Error: {api_label} API returned 403 Forbidden. The OAuth scopes granted are "
"insufficient for this operation, or the Calendar API is disabled in the Google Cloud "
"Console. Verify the scopes in scripts/gcal_oauth_setup.py and the API enablement."
)
if status == 404:
return (
f"Error: {api_label} API returned 404 Not Found. Check the event/calendar ID and the "
"calendar_id parameter."
)
if status == 429:
return f"Error: {api_label} API rate limit exceeded (429). Wait a moment and retry."
if status == 400:
return (
f"Error: {api_label} API rejected the request as invalid (400). Check the parameters. "
f"Detail: {_http_error_reason(e)}"
)
if status is not None and 500 <= status < 600:
return f"Error: {api_label} API returned a server error (HTTP {status}). Retry in a moment."
return f"Error: {api_label} API call failed (HTTP {status}). Detail: {_http_error_reason(e)}"
return f"Error: {api_label} API call failed: {e}"
def _status_report(icon: str, label: str, kind: str, description: str, steps: list[str] | None = None) -> str:
lines = [f"Status: {label} {icon} ({kind})", description]
if steps:
lines.append("")
lines.append("What to do:")
for i, s in enumerate(steps, 1):
lines.append(f"{i}. {s}")
return "\n".join(lines)
# ── Tool implementations ───────────────────────────────────────────────────────
def _gcal_status(args: dict | None = None) -> str:
"""Self-check: credentials load, the token refreshes when needed, and the API answers.
Performs one cheap calendarList().list(maxResults=1) probe so we exercise key
validation, the OAuth token, the network, and the Calendar API in a single call.
"""
# Step 1: deps + creds file + service build.
svc = _get_service()
if svc is None:
return _status_report("❌", "NOT_CONFIGURED", "action needed",
f"The Google Calendar service could not be built: {_init_error or 'unknown error'}.",
["Run scripts/gcal_oauth_setup.py to authenticate and create secrets/google_creds.json.",
"Or set the GOOGLE_CREDS_PATH env var to point at an existing credentials file."])
# Step 2: live probe — refresh-on-auth-error is handled inside _call.
try:
result = _call(lambda: svc.calendarList().list(maxResults=1).execute(), "Calendar")
except Exception as e:
return _status_report("❌", "AUTH_OR_API_ERROR", "action needed",
f"The Calendar API did not respond to the probe call: {_format_google_error(e, 'Calendar')}",
["Run scripts/gcal_oauth_setup.py to refresh / re-issue credentials.",
"If credentials are valid, verify the Google Calendar API is enabled in the Google Cloud Console."])
items = result.get("items", []) if isinstance(result, dict) else []
primary = next((c for c in items if c.get("primary")), None)
suffix = f"\nAccount: {primary.get('id')}" if primary else ""
return _status_report("✅", "READY", "ok",
"Google Calendar integration is operational: credentials load, the access token refreshes "
"automatically, and the Calendar API responds. All tools (list/get/create/update/delete/RSVP) "
"are usable." + suffix)
def _gcal_list_calendars(args: dict | None = None) -> str:
svc = _get_service()
if svc is None:
return f"Error: {_init_error}"
try:
result = _call(lambda: svc.calendarList().list().execute(), "Calendar")
except Exception as e:
return _format_google_error(e, "Calendar")
items = result.get("items", [])
if not items:
return "No calendars found."
lines = []
for cal in items:
cal_id = cal.get("id", "?")
summary = cal.get("summary", "(no name)")
primary = " [PRIMARY]" if cal.get("primary", False) else ""
lines.append(f"- {summary}{primary} (id: {cal_id})")
return "\n".join(lines)
def _gcal_list_events(args: dict) -> str:
svc = _get_service()
if svc is None:
return f"Error: {_init_error}"
calendar_id = args.get("calendar_id", "primary")
max_results = args.get("max_results", 100)
full_text = args.get("full_text")
time_zone = args.get("time_zone", "Europe/Rome")
# Accept both "time_min"/"time_max" (preferred, mirrors GCal API) and the
# legacy "start_time"/"end_time" aliases so old callers keep working.
# Default time_min to now so we never return stale past events by accident.
start_time = args.get("time_min") or args.get("start_time") or _utc_now_iso()
end_time = args.get("time_max") or args.get("end_time")
params: dict = {
"calendarId": calendar_id,
"maxResults": min(max(int(max_results), 1), 250),
"timeZone": time_zone,
"timeMin": start_time,
"singleEvents": True, # expand recurring events into individual instances
"orderBy": "startTime", # chronological order (requires singleEvents=True)
}
if end_time:
params["timeMax"] = end_time
if full_text:
params["q"] = full_text
try:
result = _call(lambda: svc.events().list(**params).execute(), "Calendar")
except Exception as e:
return _format_google_error(e, "Calendar")
items = result.get("items", [])
if not items:
return "No events found."
lines = [f"Events ({len(items)} total):"]
for ev in items:
summary = ev.get("summary", "(no title)")
start = ev.get("start", {})
end = ev.get("end", {})
start_str = start.get("dateTime") or start.get("date") or "?"
end_str = end.get("dateTime") or end.get("date") or "?"
ev_id = ev.get("id", "?")
lines.append(f"- {summary}")
lines.append(f" When: {start_str}{end_str}")
lines.append(f" ID: {ev_id}")
return "\n".join(lines)
def _gcal_get_event(args: dict) -> str:
svc = _get_service()
if svc is None:
return f"Error: {_init_error}"
event_id = args.get("event_id")
if not event_id:
return "Error: Missing required parameter 'event_id'."
calendar_id = args.get("calendar_id", "primary")
try:
result = _call(lambda: svc.events().get(calendarId=calendar_id, eventId=event_id).execute(), "Calendar")
except Exception as e:
return _format_google_error(e, "Calendar")
summary = result.get("summary", "(no title)")
description = result.get("description", "(no description)")
start = result.get("start", {})
end = result.get("end", {})
start_str = start.get("dateTime") or start.get("date") or "?"
end_str = end.get("dateTime") or end.get("date") or "?"
location = result.get("location", "(no location)")
attendees = result.get("attendees", [])
lines = [
f"Event: {summary}",
f" ID: {event_id}",
f" When: {start_str}{end_str}",
f" Location: {location}",
f" Description: {description}",
]
if attendees:
lines.append(" Attendees:")
for a in attendees:
email = a.get("email", "?")
status = a.get("responseStatus", "?")
lines.append(f" - {email} ({status})")
return "\n".join(lines)
def _gcal_create_event(args: dict) -> str:
svc = _get_service()
if svc is None:
return f"Error: {_init_error}"
summary = args.get("summary")
if not summary:
return "Error: Missing required parameter 'summary'."
start = args.get("start")
end = args.get("end")
if not start or not end:
return "Error: Missing required parameters 'start' and/or 'end'."
calendar_id = args.get("calendar_id", "primary")
# Build start/end objects: support dateTime (with timezone) or date (all-day).
def _time_obj(value: str, time_zone: str) -> dict:
if "T" in value:
return {"dateTime": value, "timeZone": time_zone}
return {"date": value}
time_zone = args.get("time_zone", "Europe/Rome")
body: dict = {
"summary": summary,
"start": _time_obj(start, time_zone),
"end": _time_obj(end, time_zone),
}
if args.get("description"):
body["description"] = args["description"]
if args.get("location"):
body["location"] = args["location"]
attendees_raw = args.get("attendees", [])
if attendees_raw:
body["attendees"] = [{"email": e} for e in attendees_raw]
if args.get("recurrence"):
body["recurrence"] = args["recurrence"] # e.g. ["RRULE:FREQ=WEEKLY;COUNT=5"]
reminders_raw = args.get("reminders")
if reminders_raw is not None:
body["reminders"] = _build_reminders(reminders_raw)
try:
result = _call(lambda: svc.events().insert(calendarId=calendar_id, body=body).execute(), "Calendar")
except Exception as e:
return _format_google_error(e, "Calendar")
ev_id = result.get("id", "?")
link = result.get("htmlLink", "")
return f"✅ Event created: {summary}\n ID: {ev_id}\n Link: {link}"
def _build_reminders(reminders_raw: list) -> dict:
"""Accept both list-of-dicts and list-of-minutes (popup only)."""
overrides = []
for r in reminders_raw:
if isinstance(r, dict):
overrides.append({"method": r.get("method", "popup"), "minutes": int(r.get("minutes", 10))})
else:
overrides.append({"method": "popup", "minutes": int(r)})
return {"useDefault": False, "overrides": overrides}
def _gcal_update_event(args: dict) -> str:
svc = _get_service()
if svc is None:
return f"Error: {_init_error}"
event_id = args.get("event_id")
if not event_id:
return "Error: Missing required parameter 'event_id'."
calendar_id = args.get("calendar_id", "primary")
time_zone = args.get("time_zone", "Europe/Rome")
# Fetch the existing event so we can patch only what changed.
try:
existing = _call(lambda: svc.events().get(calendarId=calendar_id, eventId=event_id).execute(), "Calendar")
except Exception as e:
return _format_google_error(e, "Calendar")
def _time_obj(value: str, tz: str) -> dict:
if "T" in value:
return {"dateTime": value, "timeZone": tz}
return {"date": value}
if args.get("summary"):
existing["summary"] = args["summary"]
if args.get("description") is not None:
existing["description"] = args["description"]
if args.get("location") is not None:
existing["location"] = args["location"]
if args.get("start"):
existing["start"] = _time_obj(args["start"], time_zone)
if args.get("end"):
existing["end"] = _time_obj(args["end"], time_zone)
if args.get("attendees") is not None:
existing["attendees"] = [{"email": e} for e in args["attendees"]]
if args.get("reminders") is not None:
existing["reminders"] = _build_reminders(args["reminders"])
try:
result = _call(lambda: svc.events().update(calendarId=calendar_id, eventId=event_id, body=existing).execute(), "Calendar")
except Exception as e:
return _format_google_error(e, "Calendar")
summary = result.get("summary", event_id)
return f"✅ Event updated: {summary}\n ID: {event_id}"
def _gcal_delete_event(args: dict) -> str:
svc = _get_service()
if svc is None:
return f"Error: {_init_error}"
event_id = args.get("event_id")
if not event_id:
return "Error: Missing required parameter 'event_id'."
calendar_id = args.get("calendar_id", "primary")
try:
_call(lambda: svc.events().delete(calendarId=calendar_id, eventId=event_id).execute(), "Calendar")
except Exception as e:
return _format_google_error(e, "Calendar")
return f"✅ Event {event_id} deleted."
def _gcal_respond_to_event(args: dict) -> str:
"""RSVP to an event by updating the self attendee status."""
svc = _get_service()
if svc is None:
return f"Error: {_init_error}"
event_id = args.get("event_id")
if not event_id:
return "Error: Missing required parameter 'event_id'."
response = args.get("response", "").lower()
valid = {"accepted", "declined", "tentative", "needsAction"}
if response not in valid:
return f"Error: 'response' must be one of: {', '.join(sorted(valid))}."
calendar_id = args.get("calendar_id", "primary")
try:
existing = _call(lambda: svc.events().get(calendarId=calendar_id, eventId=event_id).execute(), "Calendar")
except Exception as e:
return _format_google_error(e, "Calendar")
attendees = existing.get("attendees", [])
updated = False
for a in attendees:
if a.get("self"):
a["responseStatus"] = response
updated = True
break
if not updated:
# No self attendee found — add one.
# We need the authenticated user's email; fetch it from settings.
try:
cal_info = _call(lambda: svc.calendars().get(calendarId="primary").execute(), "Calendar")
self_email = cal_info.get("id", "")
except Exception:
self_email = ""
if self_email:
attendees.append({"email": self_email, "self": True, "responseStatus": response})
existing["attendees"] = attendees
else:
return "Error: Could not determine your email to set RSVP."
try:
result = _call(lambda: svc.events().patch(
calendarId=calendar_id,
eventId=event_id,
body={"attendees": existing["attendees"]},
sendUpdates="none",
).execute(), "Calendar")
except Exception as e:
return _format_google_error(e, "Calendar")
summary = result.get("summary", event_id)
return f"✅ RSVP set to '{response}' for event: {summary}"
# ── Tool manifest ──────────────────────────────────────────────────────────────
_REMINDER_ITEM_SCHEMA = {
"type": ["integer", "object"],
"description": "A reminder: either an integer (minutes before the event, popup) or an object.",
}
_REMINDER_ITEM_DESCRIPTION = (
"Optional custom reminders. Pass integers for popup reminders (e.g. [10, 30, 60]) "
"or dicts for full control ([{'method': 'popup', 'minutes': 10}]). Overrides calendar defaults."
)
TOOLS = [
# ── Self-check ─────────────────────────────────────────────────────────────
{
"name": "status",
"description": (
"Self-check that the Google Calendar integration is operational: verifies the OAuth "
"credentials load, the access token refreshes when needed, and the Calendar API responds, "
"by performing one cheap calendarList probe. Call this first whenever another gcal tool "
"fails, or to give the user a quick yes/no on whether Calendar is usable right now."
),
"inputSchema": {"type": "object", "properties": {}},
},
# ── Read-only ──────────────────────────────────────────────────────────────
{
"name": "list_calendars",
"description": "Lists all calendars accessible to the authenticated user. Use it to discover calendar_id values to pass to the other gcal tools.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "list_events",
"description": (
"Lists calendar events from a given calendar, ordered chronologically. "
"If time_min is omitted, defaults to NOW (current UTC time) — so you never get past events by accident. "
"If time_max is omitted, the API returns events from time_min onward up to max_results. "
"Always pass time_min and time_max explicitly when you need a specific range."
),
"inputSchema": {
"type": "object",
"properties": {
"calendar_id": {
"type": "string",
"description": "Calendar ID. Defaults to 'primary'.",
},
"time_min": {
"type": "string",
"description": "ISO 8601 lower bound (inclusive), e.g. '2025-01-01T00:00:00+01:00'. Also accepted as 'start_time'.",
},
"time_max": {
"type": "string",
"description": "ISO 8601 upper bound (exclusive). Also accepted as 'end_time'.",
},
"max_results": {
"type": "integer",
"description": "Max events to return. Default 100.",
},
"full_text": {
"type": "string",
"description": "Free-text search across title, description, location, attendees.",
},
"time_zone": {
"type": "string",
"description": "IANA timezone. Default 'Europe/Rome'.",
},
},
},
},
{
"name": "get_event",
"description": "Returns a single calendar event by ID, including attendees with their RSVP status.",
"inputSchema": {
"type": "object",
"properties": {
"event_id": {
"type": "string",
"description": "The ID of the event to retrieve.",
},
"calendar_id": {
"type": "string",
"description": "Calendar ID. Defaults to 'primary'.",
},
},
"required": ["event_id"],
},
},
# ── Write ──────────────────────────────────────────────────────────────────
{
"name": "create_event",
"description": (
"Creates a new event in the specified calendar and returns its ID + HTML link. "
"Use ISO 8601 for start/end (e.g. '2025-06-15T10:00:00' for timed events, "
"'2025-06-15' for all-day events)."
),
"inputSchema": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "Title / subject of the event.",
},
"start": {
"type": "string",
"description": "Start datetime (ISO 8601) or date (YYYY-MM-DD for all-day).",
},
"end": {
"type": "string",
"description": "End datetime (ISO 8601) or date (YYYY-MM-DD for all-day).",
},
"description": {
"type": "string",
"description": "Optional longer description / notes.",
},
"location": {
"type": "string",
"description": "Optional location string.",
},
"attendees": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of attendee email addresses.",
},
"recurrence": {
"type": "array",
"items": {"type": "string"},
"description": "Optional RRULE strings, e.g. ['RRULE:FREQ=WEEKLY;COUNT=4'].",
},
"time_zone": {
"type": "string",
"description": "IANA timezone for start/end. Default 'Europe/Rome'.",
},
"calendar_id": {
"type": "string",
"description": "Calendar ID. Defaults to 'primary'.",
},
"reminders": {
"type": "array",
"items": _REMINDER_ITEM_SCHEMA,
"description": _REMINDER_ITEM_DESCRIPTION,
},
},
"required": ["summary", "start", "end"],
},
},
{
"name": "update_event",
"description": (
"Updates an existing event. Only fields provided are changed; omitted fields keep their "
"current values. Returns the updated event ID."
),
"inputSchema": {
"type": "object",
"properties": {
"event_id": {
"type": "string",
"description": "ID of the event to update.",
},
"summary": {"type": "string", "description": "New title."},
"start": {"type": "string", "description": "New start (ISO 8601 or YYYY-MM-DD)."},
"end": {"type": "string", "description": "New end (ISO 8601 or YYYY-MM-DD)."},
"description": {"type": "string", "description": "New description."},
"location": {"type": "string", "description": "New location."},
"attendees": {
"type": "array",
"items": {"type": "string"},
"description": "Replacement attendee list (emails). Replaces all existing attendees.",
},
"time_zone": {
"type": "string",
"description": "IANA timezone for start/end. Default 'Europe/Rome'.",
},
"calendar_id": {
"type": "string",
"description": "Calendar ID. Defaults to 'primary'.",
},
"reminders": {
"type": "array",
"items": _REMINDER_ITEM_SCHEMA,
"description": _REMINDER_ITEM_DESCRIPTION,
},
},
"required": ["event_id"],
},
},
{
"name": "delete_event",
"description": "Permanently deletes a calendar event. Irreversible.",
"inputSchema": {
"type": "object",
"properties": {
"event_id": {
"type": "string",
"description": "ID of the event to delete.",
},
"calendar_id": {
"type": "string",
"description": "Calendar ID. Defaults to 'primary'.",
},
},
"required": ["event_id"],
},
},
{
"name": "respond_to_event",
"description": "Set your RSVP / attendance response (accepted, declined, tentative, needsAction) for a calendar event.",
"inputSchema": {
"type": "object",
"properties": {
"event_id": {
"type": "string",
"description": "ID of the event.",
},
"response": {
"type": "string",
"enum": ["accepted", "declined", "tentative", "needsAction"],
"description": "Your response: accepted, declined, tentative, or needsAction.",
},
"calendar_id": {
"type": "string",
"description": "Calendar ID. Defaults to 'primary'.",
},
},
"required": ["event_id", "response"],
},
},
]
# ── JSON-RPC dispatch ──────────────────────────────────────────────────────────
TOOL_DISPATCH = {
"status": _gcal_status,
"list_calendars": _gcal_list_calendars,
"list_events": _gcal_list_events,
"get_event": _gcal_get_event,
"create_event": _gcal_create_event,
"update_event": _gcal_update_event,
"delete_event": _gcal_delete_event,
"respond_to_event": _gcal_respond_to_event,
}
def _ok(req_id: Any, result: Any) -> str:
return json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result})
def _text_result(req_id: Any, text: str, is_error: bool = False) -> str:
payload: dict = {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": text}]},
}
if is_error:
payload["result"]["isError"] = True
return json.dumps(payload)
def handle_request(msg: dict) -> str | None:
method = msg.get("method", "")
req_id = msg.get("id")
if method == "initialize":
return _ok(req_id, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "gcal",
"version": "0.3.0",
},
})
if method == "notifications/initialized":
return None
if method == "tools/list":
return _ok(req_id, {"tools": TOOLS})
if method == "tools/call":
params = msg.get("params", {})
tool_name = params.get("name", "")
tool_args = params.get("arguments", {})
handler = TOOL_DISPATCH.get(tool_name)
if handler is None:
return _text_result(req_id, f"Error: Unknown tool: {tool_name}", is_error=True)
try:
text = handler(tool_args)
is_err = text.startswith("Error:")
return _text_result(req_id, text, is_error=is_err)
except Exception as e:
log(f"Unhandled exception in tool '{tool_name}': {e}")
return _text_result(req_id, f"Error: Internal error in tool '{tool_name}': {e}", is_error=True)
return json.dumps({
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
})
# ── Main loop ──────────────────────────────────────────────────────────────────
def main() -> None:
log("Starting gcal MCP server (read + write)")
# Build the service eagerly and start the background polling thread.
_start_polling()
try:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError as e:
log(f"Invalid JSON input: {e}")
continue
resp = handle_request(msg)
if resp is not None:
with _stdout_lock:
sys.stdout.write(resp + "\n")
sys.stdout.flush()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()