Add Google Calendar connector (gcal)

- connector.json: OAuth Google, scope https://www.googleapis.com/auth/calendar
  deliver: file/google_authorized_user → {secrets}/google_creds.json
  mcp_config.env.GOOGLE_CREDS_PATH = {secrets}/google_creds.json
- verify.py: loads creds + API probe (calendarList.list)
- gcal_mcp_server.py: 8 tools (status, list_calendars, list_events,
  get_event, create_event, update_event, delete_event, respond_to_event)
  + push notification polling
- icons: calendar SVG (48px + 96px)
- connectors.json index: gcal entry with auth + sha256
- SKALD.md: tabella connector + stato verify aggiornati
This commit is contained in:
2026-07-17 20:53:38 +01:00
parent 04912fccd9
commit 3a3bc4e1e9
9 changed files with 1297 additions and 1 deletions
+60
View File
@@ -56,6 +56,66 @@
}
]
},
{
"id": "gcal",
"name": "Google Calendar",
"type": "mcp_local",
"scope": "user",
"icon_small": "gcal/icon_sm.svg",
"icon_large": "gcal/icon_lg.svg",
"user_description": "Read, create, update, delete, and respond to Google Calendar events \u2014 full CRUD with push notifications.",
"requires": [
"OAUTH",
"PYTHON"
],
"tags": [
"calendar",
"mcp",
"local",
"google"
],
"auth": {
"type": "oauth2",
"provider": "google",
"scopes": [
"https://www.googleapis.com/auth/calendar"
]
},
"folder": "gcal",
"files": [
{
"path": "gcal_mcp_server.py",
"sha256": "0d1450ae552aba8035e608fa7785487459b9ecf0b05064c15fb5511d93ac92c1",
"size": 37210
},
{
"path": "gcal_oauth_setup.py",
"sha256": "4abbfcf6af50cc55ca00d283353b417acc6fee7720aa10abb0fbaa0e4aa79900",
"size": 3430
},
{
"path": "verify.py",
"sha256": "3bbc8c32bd320ed772972798371a39b03c3e52ce1dfd66a1493cda9e78e428e5",
"size": 2328
},
{
"path": "icon_sm.svg",
"sha256": "477ab05d24b40aaf4209c37e30558878fd0833dfbcfa8648b0ab9b903bd171d3",
"size": 473
},
{
"path": "icon_lg.svg",
"sha256": "b5daeeaaebba5b6d5ea31fd51bc63887225dee8aa1a42b571e49263f930895a9",
"size": 479
},
{
"path": "requirements.txt",
"sha256": "3f659cc5e5f0543f132699b06ccf9016ffe9afb40f9df4d110e0445b8d20f63e",
"size": 82
}
]
},
{
"id": "email",
"name": "Email (IMAP/SMTP)",
+55
View File
@@ -0,0 +1,55 @@
{
"id": "gcal",
"name": "Google Calendar",
"version": "0.3.0",
"type": "mcp_local",
"scope": "user",
"launch_command": "python3 gcal_mcp_server.py",
"transport": "stdio",
"requires": ["OAUTH", "PYTHON"],
"tags": ["calendar", "mcp", "local", "google"],
"dependencies": [
"google-api-python-client>=2.150.0",
"google-auth>=2.35.0",
"google-auth-oauthlib>=1.2.0"
],
"setup_instructions": [
"Install dependencies: pip install -r requirements.txt",
"Create secrets/google_oauth_client.json with {\"client_id\": \"...\", \"client_secret\": \"...\"}",
"Run: python3 gcal_oauth_setup.py (opens browser for OAuth)",
"Set GOOGLE_CREDS_PATH env var or place token at secrets/google_creds.json"
],
"docs": [
{
"lang": "en",
"description": "Read, create, update, delete, and respond to Google Calendar events. Supports push notifications for new events created by others.",
"llm_short_description": "Google Calendar MCP connector — full CRUD + RSVP + push polling."
}
],
"auth": {
"type": "oauth2",
"provider": "google",
"scopes": [
"https://www.googleapis.com/auth/calendar"
],
"deliver": {
"as": "file",
"format": "google_authorized_user",
"path": "{secrets}/google_creds.json"
}
},
"mcp_config": {
"command": "python3",
"args": ["gcal_mcp_server.py"],
"env": {
"GOOGLE_CREDS_PATH": "{secrets}/google_creds.json"
}
},
"verify": {
"command": "python3 verify.py",
"timeout_secs": 20
},
"homepage": "https://calendar.google.com",
"icon_small": "icon_sm.svg",
"icon_large": "icon_lg.svg"
}
+980
View File
@@ -0,0 +1,980 @@
#!/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()
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Generate a Google OAuth token for the Calendar API (read + write).
This script runs a local OAuth flow that:
1. Opens your browser automatically to the Google authorization page
2. Handles the callback via a local HTTP server
3. Saves the resulting token to ./secrets/google_creds.json
Required OAuth scope: https://www.googleapis.com/auth/calendar
(full access — needed for create, update, delete, respond).
No manual copy-paste required.
"""
from __future__ import annotations
import json
import os
import sys
SCOPES = [
"https://www.googleapis.com/auth/calendar",
]
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_PATH = os.path.join(_ROOT, "secrets", "google_creds.json")
_OAUTH_CLIENT_PATH = os.path.join(_ROOT, "secrets", "google_oauth_client.json")
def _load_oauth_client() -> tuple[str, str]:
if not os.path.exists(_OAUTH_CLIENT_PATH):
print(f"Missing OAuth client file: {_OAUTH_CLIENT_PATH}")
print('Create it with: {"client_id": "...", "client_secret": "..."}')
sys.exit(1)
with open(_OAUTH_CLIENT_PATH) as f:
data = json.load(f)
return data["client_id"], data["client_secret"]
def main() -> None:
try:
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
except ImportError as e:
print(f"Missing dependencies: {e}")
print("Install with: pip install google-auth google-auth-oauthlib google-api-python-client")
sys.exit(1)
creds = None
# Try to load existing credentials first.
if os.path.exists(SECRET_PATH):
print(f"Existing credentials found at {SECRET_PATH}")
try:
creds = Credentials.from_authorized_user_file(SECRET_PATH, SCOPES)
except Exception:
creds = None
if creds and creds.valid:
print("Credentials are already valid!")
print(f" Scopes: {creds.scopes}")
return
if creds and creds.expired and creds.refresh_token:
print("Token expired. Attempting refresh...")
try:
creds.refresh(Request())
print("Token refreshed successfully!")
except Exception as e:
print(f"Refresh failed: {e}")
creds = None
if not creds or not creds.valid:
client_id, client_secret = _load_oauth_client()
flow = InstalledAppFlow.from_client_config(
{
"installed": {
"client_id": client_id,
"client_secret": client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": ["http://localhost"],
}
},
SCOPES,
)
print("\nOpening browser for Google authorization...")
creds = flow.run_local_server(
port=0,
open_browser=True,
prompt="consent",
access_type="offline",
)
os.makedirs(os.path.dirname(SECRET_PATH), exist_ok=True)
with open(SECRET_PATH, "w") as f:
f.write(creds.to_json())
print(f"\n✅ Google Calendar OAuth token saved to {SECRET_PATH}")
print(f" Scopes: {creds.scopes}")
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96">
<rect x="8" y="16" width="80" height="72" rx="8" fill="#4285F4"/>
<rect x="8" y="16" width="80" height="16" rx="8" fill="#357AE8"/>
<rect x="18" y="4" width="8" height="16" rx="3" fill="#666"/>
<rect x="70" y="4" width="8" height="16" rx="3" fill="#666"/>
<text x="48" y="66" text-anchor="middle" font-family="sans-serif" font-size="36" font-weight="bold" fill="#fff">17</text>
</svg>

After

Width:  |  Height:  |  Size: 479 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<rect x="4" y="8" width="40" height="36" rx="4" fill="#4285F4"/>
<rect x="4" y="8" width="40" height="8" rx="4" fill="#357AE8"/>
<rect x="9" y="2" width="4" height="8" rx="2" fill="#666"/>
<rect x="35" y="2" width="4" height="8" rx="2" fill="#666"/>
<text x="24" y="33" text-anchor="middle" font-family="sans-serif" font-size="16" font-weight="bold" fill="#fff">17</text>
</svg>

After

Width:  |  Height:  |  Size: 473 B

+3
View File
@@ -0,0 +1,3 @@
google-api-python-client>=2.150.0
google-auth>=2.35.0
google-auth-oauthlib>=1.2.0
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Verify Google Calendar OAuth credentials are valid.
Reads GOOGLE_CREDS_PATH env var (or falls back to secrets/google_creds.json),
loads the authorized_user credentials, and does one cheap API probe
(calendarList.list(maxResults=1)).
Output: single JSON object on stdout:
{"ok": true, "message": "..."}
{"ok": false, "message": "..."}
"""
from __future__ import annotations
import json
import os
import sys
def main() -> None:
creds_path = os.environ.get(
"GOOGLE_CREDS_PATH",
os.path.join(os.path.dirname(__file__), "secrets", "google_creds.json"),
)
if not os.path.exists(creds_path):
_fail(f"Credentials file not found at {creds_path}. Run gcal_oauth_setup.py first.")
return
try:
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
except ImportError as e:
_fail(f"Missing dependencies: {e}. Install google-api-python-client and google-auth.")
return
try:
creds = Credentials.from_authorized_user_file(
creds_path,
["https://www.googleapis.com/auth/calendar"],
)
except Exception as e:
_fail(f"Failed to load credentials: {e}")
return
if creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
with open(creds_path, "w") as f:
f.write(creds.to_json())
except Exception as e:
_fail(f"Token refresh failed: {e}. Re-run gcal_oauth_setup.py.")
return
try:
service = build("calendar", "v3", credentials=creds)
result = service.calendarList().list(maxResults=1).execute()
items = result.get("items", [])
primary = next((c for c in items if c.get("primary")), None)
account = primary.get("id", "?") if primary else "?"
_ok(f"Google Calendar credentials valid. Account: {account}")
except Exception as e:
_fail(f"API probe failed: {e}")
def _ok(message: str) -> None:
json.dump({"ok": True, "message": message}, sys.stdout)
def _fail(message: str) -> None:
json.dump({"ok": False, "message": message}, sys.stdout)
sys.exit(1)
if __name__ == "__main__":
main()