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:
@@ -440,8 +440,9 @@ sudo find /var/www/connectors.skaldagent.net/ -type f -exec chmod 644 {} \;
|
|||||||
|----|------|------|-------|------|--------|
|
|----|------|------|-------|------|--------|
|
||||||
| `tavily` | Tavily | `mcp_remote` | `global` | api_key (`{SECRET:tavilyApiKey}` in URL) | `verify.py` (HTTP probe `/search`) |
|
| `tavily` | Tavily | `mcp_remote` | `global` | api_key (`{SECRET:tavilyApiKey}` in URL) | `verify.py` (HTTP probe `/search`) |
|
||||||
| `gmail` | Gmail | `mcp_local` | `user` | oauth2 (Google) + deliver: `file/google_authorized_user` | ⏳ Fase 2 — OAuth via loopback listener |
|
| `gmail` | Gmail | `mcp_local` | `user` | oauth2 (Google) + deliver: `file/google_authorized_user` | ⏳ Fase 2 — OAuth via loopback listener |
|
||||||
|
| `gcal` | Google Calendar | `mcp_local` | `user` | oauth2 (Google) + deliver: `file/google_authorized_user` | `verify.py` (creds load + API probe) |
|
||||||
| `email` | Email (IMAP/SMTP) | `mcp_local` | `user` | password (env) | `verify.py` (IMAP+SMTP probe) |
|
| `email` | Email (IMAP/SMTP) | `mcp_local` | `user` | password (env) | `verify.py` (IMAP+SMTP probe) |
|
||||||
| `ssh` | SSH Remote Access | `mcp_local` | `user` | none (auth runtime per-alias) | — (nessun setup credential) |
|
| `ssh` | SSH Remote Access | `mcp_local` | `user` | none (auth runtime per-alias) | — (nessun setup credential) |
|
||||||
**Stato del verify-before-save in skald**: `email` e `tavily` hanno `verify` completo
|
**Stato del verify-before-save in skald**: `email`, `tavily`, e `gcal` hanno `verify` completo
|
||||||
(script + JSON output); `gmail` aspetta la Fase 2 (OAuth via loopback listener).
|
(script + JSON output); `gmail` aspetta la Fase 2 (OAuth via loopback listener).
|
||||||
Un connector senza `verify` viene attivato senza test — vedi § Senza verify.
|
Un connector senza `verify` viene attivato senza test — vedi § Senza verify.
|
||||||
|
|||||||
@@ -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",
|
"id": "email",
|
||||||
"name": "Email (IMAP/SMTP)",
|
"name": "Email (IMAP/SMTP)",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
Executable
+980
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,3 @@
|
|||||||
|
google-api-python-client>=2.150.0
|
||||||
|
google-auth>=2.35.0
|
||||||
|
google-auth-oauthlib>=1.2.0
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user