commit dedd09d7c7d50a89184a51e3b6a946268b3203fe Author: xavix-yo Date: Thu Jul 16 18:49:01 2026 +0100 Initial commit: marketplace structure with connectors.json and index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4c33b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +*.pyc +__pycache__/ +.env +secrets/ +*.egg-info/ diff --git a/SKALD.md b/SKALD.md new file mode 100644 index 0000000..c8ad096 --- /dev/null +++ b/SKALD.md @@ -0,0 +1,262 @@ +# Skald Connectors Marketplace + +_Updated: 2026-07-16_ + +## Cos'è + +Il **marketplace** è il catalogo dei connector testati per Skald. Ogni connector è un adattatore che permette a Skald di interfacciarsi con un servizio esterno (API, email, calendario, ricerca, messaggistica, ecc.). + +Due tipi di connector: + +- **`mcp_remote`** — un MCP server già hosted, accessibile via URL (es. Tavily). +- **`mcp_local`** — script Python/Node da eseguire lato client (es. Gmail, Google Calendar). + +## Struttura directory + +``` +connectors/ +├── connectors.json ← INDICE (radice di fiducia unica) +├── index.html ← Catalogo UI (legge connectors.json via fetch) +├── gmail/ ← Un connector per cartella +│ ├── connector.json ← Configurazione tecnica +│ ├── gmail_mcp_server.py ← Script MCP +│ ├── gmail_oauth_setup.py ← Script setup OAuth +│ ├── requirements.txt ← Dipendenze Python +│ ├── icon_sm.svg ← Icona piccola (48×48) +│ └── icon_lg.svg ← Icona grande (es. preview) +└── tavily/ + ├── connector.json + ├── icon_sm.png + └── icon_lg.png +``` + +## Schema — connectors.json (root) + +Questo è l'**unico punto di fiducia**. Contiene `type`, `scope` e gli sha256 dei file di ogni connector. +Non ha hash di sé stesso — in futuro potrà essere firmato digitalmente. + +```json +{ + "version": 1, + "connectors": [ + { + "id": "gmail", + "name": "Gmail", + "type": "mcp_local", + "scope": "user", + "icon_small": "gmail/icon_sm.svg", + "icon_large": "gmail/icon_lg.svg", + "user_description": "Read, send, and manage Gmail emails via OAuth...", + "requires": ["OAUTH", "PYTHON"], + "tags": ["email", "mcp", "local", "google"], + "folder": "gmail", + "files": [ + {"path": "gmail_mcp_server.py", "sha256": "a50d4da9621f7a4b092f...", "size": 46772}, + {"path": "gmail_oauth_setup.py", "sha256": "e488acb289c43a3e6d54...", "size": 3627}, + {"path": "icon_lg.svg", "sha256": "93c8d9c8dae96f0206e5...", "size": 254}, + {"path": "icon_sm.svg", "sha256": "029d7f5d81de6cf2b17b...", "size": 251}, + {"path": "requirements.txt", "sha256": "3f659cc5e5f0543f1326...", "size": 82} + ] + } + ] +} +``` + +### Campi dell'indice + +| Campo | Obbligatorio | Descrizione | +|-------|-------------|-------------| +| `id` | ✅ | Identificatore unico (kebab-case) | +| `name` | ✅ | Nome visualizzato | +| `type` | ✅ | `mcp_remote` o `mcp_local` | +| `scope` | ✅ | `global` o `user` | +| `icon_small` | ✅ | Path relativo dalla root del marketplace | +| `icon_large` | ✅ | Path relativo dalla root del marketplace | +| `user_description` | ✅ | Descrizione breve per la UI | +| `requires` | ✅ | Array di enum requisiti | +| `tags` | ✅ | Array di tag per filtraggio | +| `folder` | ✅ | Nome della cartella del connector | +| `files` | ✅ | Array di file con sha256 (NO self-hash) | + +## Schema — connector.json (per cartella) + +Configurazione tecnica per l'attivazione del connector. + +```json +{ + "id": "gmail", + "name": "Gmail", + "version": "1.0.0", + "type": "mcp_local", + "scope": "user", + "launch_command": "python3 gmail_mcp_server.py", + "transport": "stdio", + "requires": ["OAUTH", "PYTHON"], + "tags": ["email", "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 gmail_oauth_setup.py (opens browser for OAuth)", + "Set GMAIL_CREDS_PATH env var or place token at secrets/gmail_creds.json" + ], + "docs": [ + { + "lang": "en", + "description": "Full description for human users...", + "llm_short_description": "One-line description for LLM context..." + } + ], + "auth": { + "type": "oauth2", + "provider": "google", + "scopes": [ + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.labels" + ] + }, + "mcp_config": { + "command": "python3", + "args": ["gmail_mcp_server.py"], + "env": { + "GMAIL_CREDS_PATH": "{secrets}/gmail_creds.json" + } + }, + "homepage": "https://mail.google.com", + "icon_small": "icon_sm.svg", + "icon_large": "icon_lg.svg" +} +``` + +### Campi del connector.json + +| Campo | Obbligatorio | Descrizione | +|-------|-------------|-------------| +| `id` | ✅ | Identificatore unico (match con folder name) | +| `name` | ✅ | Nome visualizzato | +| `version` | ✅ | SemVer | +| `type` | ✅ | `mcp_remote` o `mcp_local` | +| `scope` | ✅ | `global` o `user` | +| `requires` | ✅ | Array di enum requisiti | +| `tags` | ✅ | Array di tag | +| `auth` | ✅ | Oggetto configurazione autenticazione | +| `docs` | ✅ | Array di documentazione multilingua | +| `icon_small` | ✅ | Nome file icona nella cartella locale | +| `icon_large` | ✅ | Nome file icona nella cartella locale | +| `launch_command` | solo `mcp_local` | Comando per avviare il server MCP | +| `transport` | solo `mcp_local` | `stdio` (default) | +| `dependencies` | consigliato | Dipendenze Python/Node | +| `setup_instructions` | consigliato | Passi per configurare il connector | +| `mcp_config` | solo `mcp_local` | Configurazione per l'MCP client | +| `homepage` | opzionale | URL del servizio | + +## Enum riservati + +### type (tipo di connector) + +| Valore | Descrizione | Esempi | +|--------|-------------|--------| +| `mcp_remote` | Server MCP hosted, accessibile via URL | Tavily, Weather | +| `mcp_local` | Script da eseguire localmente | Gmail, Google Calendar, WhatsApp | +| `script` | Script standalone (non MCP) | *(futuro)* | + +### scope (ambito di configurazione) + +| Valore | Descrizione | Esempi | +|--------|-------------|--------| +| `global` | Una singola istanza/config per tutto il sistema | Tavily, Weather, Google Trends | +| `user` | Ogni utente ha la propria istanza/autenticazione | Gmail, WhatsApp, Google Calendar | + +### requires (prerequisiti) + +| Valore | Descrizione | +|--------|-------------| +| `API_KEY` | Richiede una chiave API da configurare | +| `OAUTH` | Richiede autenticazione OAuth (Google, ecc.) | +| `DOCKER` | Richiede Docker Engine | +| `NODE` | Richiede Node.js runtime | +| `PYTHON` | Richiede Python 3 | +| `SECRETS_DIR` | Richiede file di credenziali in `secrets/` | + +## Campo auth + +Struttura che descrive come il connector gestisce l'autenticazione: + +```json +// API key in query string +{"type": "api_key", "delivery": "query", "param": "tavilyApiKey"} + +// API key in header +{"type": "api_key", "delivery": "header", "param": "X-API-Key"} + +// OAuth2 +{"type": "oauth2", "provider": "google", "scopes": ["...", "..."]} + +// Nessuna autenticazione +{"type": "none"} +``` + +## Convenzioni icone + +- **Formato**: SVG per icone vettoriali (meglio per retina/zoom), PNG per raster +- **Nome**: `icon_sm.{svg|png}` (small, ~48×48px), `icon_lg.{svg|png}` (large, ~96×96px) +- **Path**: relativo alla cartella del connector +- **Nell'indice** la path è `{folder}/{filename}` (es. `gmail/icon_sm.svg`) + +## Integrità file (sha256) + +- Gli hash sha256 sono **solo in `connectors.json`** (l'indice) +- `connector.json` **non contiene hash di sé stesso** — è il file manifesto +- L'indice è l'unica radice di fiducia; in futuro si può firmare digitalmente solo l'indice +- Sui file locali, per calcolare/aggiornare gli hash: + ```bash + python3 -c "import hashlib; print(hashlib.sha256(open('file.py','rb').read()).hexdigest())" + ``` + +## Workflow locale + +1. Lavora su file nella cartella `connectors/` +2. Modifica `connectors.json`, `connector.json`, script, icone +3. **Prima del deploy** aggiorna gli sha256 in `connectors.json`: + ```bash + python3 scripts/update_hashes.py + ``` +4. Fai l'upload sul server con rsync: + ```bash + rsync -avz --delete connectors/ dguiducci@skald-server:/var/www/connectors.skaldagent.net/ + ``` +5. Verifica su `https://connectors.skaldagent.net/` + +## Deploy su server remoto + +Il server remoto è: + +- **Host**: skald-home-server (192.168.1.100 / 145.40.169.107) +- **User**: dguiducci +- **Path**: `/var/www/connectors.skaldagent.net/` +- **Proprietario**: `caddy:caddy` +- **Sudo**: richiesto per scrivere in `/var/www/` + +Comando di deploy (da eseguire con sudo o con rsync): + +```bash +# Sync se il server permette rsync via SSH +rsync -avz --delete ./connectors/ dguiducci@skald-server:/var/www/connectors.skaldagent.net/ +``` + +Dopo il deploy, verificare i permessi: +```bash +sudo chown -R caddy:caddy /var/www/connectors.skaldagent.net/ +sudo find /var/www/connectors.skaldagent.net/ -type f -exec chmod 644 {} \; +``` + +## Connector attuali + +| ID | Nome | Tipo | Scope | +|----|------|------|-------| +| `tavily` | Tavily | `mcp_remote` | `global` | +| `gmail` | Gmail | `mcp_local` | `user` | diff --git a/connectors/connectors.json b/connectors/connectors.json new file mode 100644 index 0000000..98ac64a --- /dev/null +++ b/connectors/connectors.json @@ -0,0 +1,82 @@ +{ + "version": 1, + "connectors": [ + { + "id": "gmail", + "name": "Gmail", + "type": "mcp_local", + "scope": "user", + "icon_small": "gmail/icon_sm.svg", + "icon_large": "gmail/icon_lg.svg", + "user_description": "Read, send, and manage Gmail emails via OAuth \u2014 full MCP integration with push notifications.", + "requires": [ + "OAUTH", + "PYTHON" + ], + "tags": [ + "email", + "mcp", + "local", + "google" + ], + "folder": "gmail", + "files": [ + { + "path": "gmail_mcp_server.py", + "sha256": "a50d4da9621f7a4b092f4e3c5ae85dec5f466783372f78ca7e792046dc01c673", + "size": 46772 + }, + { + "path": "gmail_oauth_setup.py", + "sha256": "e488acb289c43a3e6d541140d254451ac5688c0014e8959619375b564afe8748", + "size": 3627 + }, + { + "path": "icon_lg.svg", + "sha256": "93c8d9c8dae96f0206e5ae3fcfe89dff3a08553ed48badf504a5abd09f5596b0", + "size": 254 + }, + { + "path": "icon_sm.svg", + "sha256": "029d7f5d81de6cf2b17bc60c878d465521c6817a313381bc71d625e93be19c8a", + "size": 251 + }, + { + "path": "requirements.txt", + "sha256": "3f659cc5e5f0543f132699b06ccf9016ffe9afb40f9df4d110e0445b8d20f63e", + "size": 82 + } + ] + }, + { + "id": "tavily", + "name": "Tavily", + "type": "mcp_remote", + "scope": "global", + "icon_small": "tavily/icon_sm.png", + "icon_large": "tavily/icon_lg.png", + "user_description": "Web search API for LLMs \u2014 real-time results, news, and content extraction.", + "requires": [ + "API_KEY" + ], + "tags": [ + "search", + "mcp", + "remote" + ], + "folder": "tavily", + "files": [ + { + "path": "icon_lg.png", + "sha256": "92962ea1d49f272665262d55ecdea929972d3efab5261fc6ffea632803fceaf8", + "size": 24264 + }, + { + "path": "icon_sm.png", + "sha256": "92962ea1d49f272665262d55ecdea929972d3efab5261fc6ffea632803fceaf8", + "size": 24264 + } + ] + } + ] +} \ No newline at end of file diff --git a/connectors/gmail/connector.json b/connectors/gmail/connector.json new file mode 100644 index 0000000..0459888 --- /dev/null +++ b/connectors/gmail/connector.json @@ -0,0 +1,57 @@ +{ + "id": "gmail", + "name": "Gmail", + "version": "1.0.0", + "type": "mcp_local", + "launch_command": "python3 gmail_mcp_server.py", + "transport": "stdio", + "requires": [ + "OAUTH", + "PYTHON" + ], + "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 gmail_oauth_setup.py (opens browser for OAuth)", + "Set GMAIL_CREDS_PATH env var or place token at secrets/gmail_creds.json" + ], + "docs": [ + { + "lang": "en", + "description": "Full Gmail integration: read, send, modify, and manage emails with OAuth2 authentication. Supports push notifications via history polling.", + "llm_short_description": "Gmail MCP server: read, send, modify, and manage Gmail messages. Requires OAuth setup with Google Cloud Console. Tools: list_messages, get_message, get_thread, send_message, modify_message, list_labels, create_label, get_profile, download_attachments." + } + ], + "mcp_config": { + "command": "python3", + "args": [ + "gmail_mcp_server.py" + ], + "env": { + "GMAIL_CREDS_PATH": "{secrets}/gmail_creds.json" + } + }, + "homepage": "https://mail.google.com", + "icon_small": "icon_sm.svg", + "icon_large": "icon_lg.svg", + "scope": "user", + "tags": [ + "email", + "mcp", + "local", + "google" + ], + "auth": { + "type": "oauth2", + "provider": "google", + "scopes": [ + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.labels" + ] + } +} \ No newline at end of file diff --git a/connectors/gmail/gmail_mcp_server.py b/connectors/gmail/gmail_mcp_server.py new file mode 100644 index 0000000..a8adad5 --- /dev/null +++ b/connectors/gmail/gmail_mcp_server.py @@ -0,0 +1,1243 @@ +#!/usr/bin/env python3 +"""Google Gmail MCP server (JSON-RPC 2.0 over stdio). + +Capabilities (callable as `mcp__gmail__`): + status — self-check: credentials, token refresh, API reachability + list_messages — list messages with optional query / label filter + get_message — read a single message by ID (with optional body) + get_thread — read all messages in a thread + list_labels — list labels/folders with message counts + modify_message — add/remove labels (mark read, archive, star) + send_message — send an email (supports in-thread replies + file attachments) + get_profile — account info (email, totals) + create_label — create a new label + download_attachments — save all attachments from a message to disk + +Provides read, modify, and send access to Gmail via the Gmail API v1. + +Credentials are read from ./secrets/gmail_creds.json by default. +Override with GMAIL_CREDS_PATH env var. + +Run scripts/gmail_oauth_setup.py first to generate the OAuth token. +""" + +from __future__ import annotations + +import base64 +import json +import mimetypes +import os +import re +import sys +import threading +import time +from email.message import EmailMessage +from html.parser import HTMLParser +from typing import Any, Callable + +# Log to stderr so stdout stays clean for JSON-RPC. +def log(msg: str) -> None: + print(f"[gmail_mcp] {msg}", file=sys.stderr, flush=True) + +# Protects all stdout writes (main request-handling 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() + + +# State for incremental polling via the Gmail History API. +_last_history_id: str | None = None +_poll_thread: threading.Thread | None = None +_POLL_INTERVAL_SECS = 60 + + +def _start_polling() -> None: + """Build the service eagerly, record the initial historyId, start poll thread.""" + global _last_history_id, _poll_thread + svc = _get_service() + if svc is None: + log("Gmail push polling disabled: service not available.") + return + try: + profile = _call(lambda: svc.users().getProfile(userId="me").execute(), "Gmail") + _last_history_id = str(profile.get("historyId", "")) + log(f"Gmail polling started (historyId={_last_history_id}, interval={_POLL_INTERVAL_SECS}s).") + except Exception as e: + log(f"Failed to get initial historyId, polling disabled: {_format_google_error(e, 'Gmail')}") + return + _poll_thread = threading.Thread(target=_poll_loop, daemon=True, name="gmail-poll") + _poll_thread.start() + + +def _poll_loop() -> None: + while True: + time.sleep(_POLL_INTERVAL_SECS) + _poll_once() + + +def _poll_once() -> None: + global _last_history_id + svc = _get_service() + if svc is None or not _last_history_id: + return + try: + result = _call(lambda: svc.users().history().list( + userId="me", + startHistoryId=_last_history_id, + labelId="INBOX", + historyTypes=["messageAdded"], + ).execute(), "Gmail") + + # Always advance the cursor, even if no new messages. + new_history_id = result.get("historyId") + if new_history_id: + _last_history_id = str(new_history_id) + + for record in result.get("history", []): + for added in record.get("messagesAdded", []): + msg_stub = added.get("message", {}) + if "INBOX" not in msg_stub.get("labelIds", []): + continue + msg_id = msg_stub.get("id") + if not msg_id: + continue + _fetch_and_emit_email(svc, msg_id) + + except Exception as e: + log(f"Gmail history poll error: {_format_google_error(e, 'Gmail')}") + + +def _fetch_and_emit_email(svc: Any, msg_id: str) -> None: + """Fetch metadata for a message and emit an event/new_email notification.""" + try: + msg = _call(lambda: svc.users().messages().get( + userId="me", + id=msg_id, + format="metadata", + metadataHeaders=["Subject", "From", "Date"], + ).execute(), "Gmail") + headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} + _emit_notification("event/new_email", { + "message_id": msg_id, + "thread_id": msg.get("threadId"), + "subject": headers.get("Subject", "(no subject)"), + "from": headers.get("From", "?"), + "date": headers.get("Date", "?"), + "snippet": msg.get("snippet", "")[:300], + }) + log(f"Notification emitted: new email {msg_id} from {headers.get('From', '?')!r}") + except Exception as e: + log(f"Failed to fetch metadata for message {msg_id}: {_format_google_error(e, 'Gmail')}") + + +# ── 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 Gmail 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( + "GMAIL_CREDS_PATH", + os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "secrets", "gmail_creds.json"), + ) + + if not os.path.exists(_creds_path): + _init_error = ( + f"Credentials file not found at {_creds_path}. " + "Run scripts/gmail_oauth_setup.py first, or set GMAIL_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 + + # Auto-refresh if expired; fail hard if we cannot refresh (need re-auth). + try: + if not creds.valid: + if creds.expired and creds.refresh_token: + creds.refresh(Request()) + _persist_creds() + log("Token refreshed and saved.") + else: + _init_error = "Credentials invalid and cannot be refreshed. Re-run scripts/gmail_oauth_setup.py." + log(_init_error) + return None + except Exception as e: + _init_error = f"Failed to refresh credentials: {e}" + log(_init_error) + return None + + try: + service = build("gmail", "v1", credentials=creds) + except Exception as e: + _init_error = f"Failed to build Gmail service: {e}" + log(_init_error) + return None + + log(f"Gmail 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/gmail_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/gmail_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 Gmail API is disabled in the Google Cloud " + "Console. Verify the scopes in scripts/gmail_oauth_setup.py and the API enablement." + ) + if status == 404: + return ( + f"Error: {api_label} API returned 404 Not Found. Check the message/thread/attachment ID." + ) + 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) + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + + +def _decode_body(parts: Any, mime_type: str = "text/plain") -> str: + """Recursively extract the body of the given MIME type from MIME parts.""" + if isinstance(parts, list): + for part in parts: + if part.get("mimeType", "") == mime_type: + data = part.get("body", {}).get("data", "") + if data: + return _safe_b64decode(data) + if "parts" in part: + result = _decode_body(part["parts"], mime_type) + if result: + return result + return "" + + +def _safe_b64decode(data: str) -> str: + """Decode URL-safe base64 to string.""" + try: + # Add padding if needed. + padded = data + "=" * (4 - len(data) % 4) if len(data) % 4 else data + decoded = base64.urlsafe_b64decode(padded) + return decoded.decode("utf-8", errors="replace") + except Exception: + return "(unable to decode)" + + +class _HTMLTextExtractor(HTMLParser): + """Collect readable text from HTML, skipping scripts/styles and adding + newlines around block-level tags. convert_charrefs=True unescapes entities.""" + + _SKIP = {"script", "style", "head"} + _BLOCK = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6"} + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self._chunks: list[str] = [] + self._skip_depth = 0 + + def handle_starttag(self, tag: str, attrs: Any) -> None: + if tag in self._SKIP: + self._skip_depth += 1 + elif tag == "br": + self._chunks.append("\n") + + def handle_endtag(self, tag: str) -> None: + if tag in self._SKIP and self._skip_depth: + self._skip_depth -= 1 + elif tag in self._BLOCK: + self._chunks.append("\n") + + def handle_data(self, data: str) -> None: + if not self._skip_depth: + self._chunks.append(data) + + def get_text(self) -> str: + return re.sub(r"\n{3,}", "\n\n", "".join(self._chunks)).strip() + + +def _html_to_text(html_str: str) -> str: + """Convert an HTML email body to readable plain text (stdlib only).""" + try: + parser = _HTMLTextExtractor() + parser.feed(html_str) + return parser.get_text() + except Exception: + return html_str + + +def _format_datetime(ts_millis: int | None) -> str: + """Format a unix timestamp in milliseconds to ISO-like string.""" + if ts_millis is None: + return "?" + return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts_millis / 1000)) + + +def _format_message_summary(msg: dict) -> str: + """Format a message object (from list with metadata) into a summary line.""" + mid = msg.get("id", "?") + headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} + # When listing with metadata, headers might be elsewhere. + payload = msg.get("payload", {}) + if not headers: + headers = {h["name"]: h["value"] for h in payload.get("headers", [])} + thread_id = msg.get("threadId", "?") + subject = headers.get("Subject", "(no subject)") + sender = headers.get("From", "?") + date = headers.get("Date", "?") + snippet = msg.get("snippet", "")[:80] + return f"- {subject}\n From: {sender} | Date: {date} | ID: {mid} | Thread: {thread_id}\n {snippet}" + + +def _collect_attachments(parts: Any, results: list) -> None: + """Recursively collect attachment filenames and IDs from MIME parts.""" + if not parts: + return + for part in parts: + filename = part.get("filename", "") + attachment_id = part.get("body", {}).get("attachmentId", "") + if filename and attachment_id: + results.append({"filename": filename, "attachmentId": attachment_id}) + if "parts" in part: + _collect_attachments(part["parts"], results) + + +# ── Tool implementations ─────────────────────────────────────────────────────── + + +def _gmail_status(args: dict | None = None) -> str: + """Self-check: credentials load, the token refreshes when needed, and the API answers. + + Performs one cheap users().getProfile(userId='me') probe so we exercise the + OAuth token, the network, and the Gmail 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 Gmail service could not be built: {_init_error or 'unknown error'}.", + ["Run scripts/gmail_oauth_setup.py to authenticate and create secrets/gmail_creds.json.", + "Or set the GMAIL_CREDS_PATH env var to point at an existing credentials file."]) + + # Step 2: live probe — refresh-on-auth-error is handled inside _call. + try: + profile = _call(lambda: svc.users().getProfile(userId="me").execute(), "Gmail") + except Exception as e: + return _status_report("❌", "AUTH_OR_API_ERROR", "action needed", + f"The Gmail API did not respond to the probe call: {_format_google_error(e, 'Gmail')}", + ["Run scripts/gmail_oauth_setup.py to refresh / re-issue credentials.", + "If credentials are valid, verify the Gmail API is enabled in the Google Cloud Console."]) + + email = profile.get("emailAddress", "?") + return _status_report("✅", "READY", "ok", + "Google Gmail integration is operational: credentials load, the access token refreshes " + "automatically, and the Gmail API responds. All tools (list/get/thread/labels/modify/send/" + "download) are usable.\n" + f"Account: {email}") + + +def _gmail_list_messages(args: dict) -> str: + """List messages with optional filters.""" + svc = _get_service() + if svc is None: + return f"Error: {_init_error}" + + query = args.get("query", "") + max_results = min(args.get("max_results", 20), 50) + label_ids = args.get("label_ids") + page_token = args.get("page_token") + + params: dict = { + "userId": "me", + "maxResults": max_results, + } + if query: + params["q"] = query + if label_ids: + if isinstance(label_ids, str): + label_ids = [label_ids] + params["labelIds"] = label_ids + if page_token: + params["pageToken"] = page_token + + try: + result = _call(lambda: svc.users().messages().list(**params).execute(), "Gmail") + except Exception as e: + return _format_google_error(e, "Gmail") + + items = result.get("messages", []) + if not items: + return "No messages found." + + # Fetch full metadata for each message. + lines = [f"Messages ({len(items)} total):"] + for entry in items: + try: + msg = _call(lambda e=entry: svc.users().messages().get( + userId="me", id=e["id"], format="metadata", + metadataHeaders=["Subject", "From", "Date"], + ).execute(), "Gmail") + lines.append(_format_message_summary(msg)) + except Exception as e: + lines.append(f"- {entry['id']} (error fetching: {_http_error_reason(e)})") + + # Add paging info. + next_token = result.get("nextPageToken") + if next_token: + lines.append(f"\nMore results available. Use page_token='{next_token}' to get next page.") + + return "\n".join(lines) + + +def _gmail_get_message(args: dict) -> str: + """Get full content of a single message by ID.""" + svc = _get_service() + if svc is None: + return f"Error: {_init_error}" + + msg_id = args.get("message_id") + if not msg_id: + return "Error: Missing required parameter 'message_id'." + include_body = args.get("include_body", True) + + fmt = "full" if include_body else "metadata" + meta_headers = [] if include_body else ["Subject", "From", "To", "Date"] + + try: + msg = _call(lambda: svc.users().messages().get( + userId="me", id=msg_id, format=fmt, + **({"metadataHeaders": meta_headers} if meta_headers else {}), + ).execute(), "Gmail") + except Exception as e: + return _format_google_error(e, "Gmail") + + payload = msg.get("payload", {}) + headers = {h["name"]: h["value"] for h in payload.get("headers", [])} + + lines = [ + f"ID: {msg.get('id', '?')}", + f"Thread: {msg.get('threadId', '?')}", + f"From: {headers.get('From', '?')}", + f"To: {headers.get('To', '?')}", + f"Date: {headers.get('Date', '?')}", + f"Subject: {headers.get('Subject', '(no subject)')}", + f"Labels: {', '.join(msg.get('labelIds', []))}", + ] + + # List attachment filenames (needs the full payload). Download them via + # download_attachments, which only needs this message_id. + attachments: list = [] + _collect_attachments(payload.get("parts", []), attachments) + if attachments: + lines.append(f"Attachments: {', '.join(a['filename'] for a in attachments)}") + + if include_body: + parts = payload.get("parts", []) + body_text = _decode_body(parts) # prefer text/plain + body_label = "--- Body ---" + if not body_text: + # Fall back to the HTML part, converted to readable text. + html_body = _decode_body(parts, "text/html") + if html_body: + body_text = _html_to_text(html_body) + body_label = "--- Body (converted from HTML) ---" + if not body_text: + # Single-part message: the body lives inline on the payload. + body_data = payload.get("body", {}).get("data", "") + if body_data: + decoded = _safe_b64decode(body_data) + if payload.get("mimeType", "") == "text/html": + body_text = _html_to_text(decoded) + body_label = "--- Body (converted from HTML) ---" + else: + body_text = decoded + if body_text: + lines.append(f"\n{body_label}") + # Truncate very long bodies. + if len(body_text) > 10000: + lines.append(body_text[:10000] + "\n... [truncated at 10000 chars]") + else: + lines.append(body_text) + else: + lines.append("\n(no text body found)") + + return "\n".join(lines) + + +def _gmail_get_thread(args: dict) -> str: + """Get an entire thread (all messages in it).""" + svc = _get_service() + if svc is None: + return f"Error: {_init_error}" + + thread_id = args.get("thread_id") + if not thread_id: + return "Error: Missing required parameter 'thread_id'." + + try: + thread = _call(lambda: svc.users().threads().get( + userId="me", id=thread_id, format="metadata", + metadataHeaders=["Subject", "From", "Date"], + ).execute(), "Gmail") + except Exception as e: + return _format_google_error(e, "Gmail") + + messages = thread.get("messages", []) + subject = "" + lines = [f"Thread: {thread_id} ({len(messages)} messages)"] + for i, msg in enumerate(messages, 1): + headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} + if not subject: + subject = headers.get("Subject", "(no subject)") + lines.append(f"\n[{i}] From: {headers.get('From', '?')} | Date: {headers.get('Date', '?')}") + lines.append(f" ID: {msg.get('id', '?')}") + snippet = msg.get("snippet", "") + if snippet: + lines.append(f" {snippet[:200]}") + + if subject: + lines.insert(1, f"Subject: {subject}") + + return "\n".join(lines) + + +def _gmail_list_labels(args: dict) -> str: + """List all labels/categories in the Gmail account.""" + svc = _get_service() + if svc is None: + return f"Error: {_init_error}" + + try: + result = _call(lambda: svc.users().labels().list(userId="me").execute(), "Gmail") + except Exception as e: + return _format_google_error(e, "Gmail") + + items = result.get("labels", []) + if not items: + return "No labels found." + + lines = ["Labels:"] + for lbl in items: + lid = lbl.get("id", "?") + name = lbl.get("name", "?") + label_type = lbl.get("type", "?") + msg_count = lbl.get("messagesTotal", "?") + unread = lbl.get("messagesUnread", 0) + lines.append(f"- {name} ({lid}) [{label_type}] — {msg_count} total, {unread} unread") + + return "\n".join(lines) + + +def _gmail_modify_message(args: dict) -> str: + """Modify message labels (add/remove labels, mark read/archive/star).""" + svc = _get_service() + if svc is None: + return f"Error: {_init_error}" + + msg_id = args.get("message_id") + if not msg_id: + return "Error: Missing required parameter 'message_id'." + + add_labels = args.get("add_labels", []) + remove_labels = args.get("remove_labels", []) + + if isinstance(add_labels, str): + add_labels = [add_labels] + if isinstance(remove_labels, str): + remove_labels = [remove_labels] + + body: dict = {} + if add_labels: + body["addLabelIds"] = add_labels + if remove_labels: + body["removeLabelIds"] = remove_labels + + try: + _call(lambda: svc.users().messages().modify(userId="me", id=msg_id, body=body).execute(), "Gmail") + except Exception as e: + return _format_google_error(e, "Gmail") + + changes = [] + if add_labels: + changes.append(f"added labels: {add_labels}") + if remove_labels: + changes.append(f"removed labels: {remove_labels}") + return f"✅ Message {msg_id} modified: {'; '.join(changes)}" + + +# messages.send embeds the whole message as base64 inside the JSON request body; +# Gmail caps that request around 35 MB and base64 inflates the payload ~33%, so we +# reject well before the ceiling with a clear error instead of an opaque 400. +_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024 + + +def _gmail_send_message(args: dict) -> str: + """Send an email message, optionally with one or more file attachments.""" + svc = _get_service() + if svc is None: + return f"Error: {_init_error}" + + to = args.get("to") + subject = args.get("subject", "") + body_text = args.get("body", "") + cc = args.get("cc") + bcc = args.get("bcc") + in_reply_to = args.get("in_reply_to") + thread_id = args.get("thread_id") + attachments = args.get("attachments") or [] + + if not to: + return "Error: Missing required parameter 'to'." + + # Accept a single path or an array (mirrors add_labels / remove_labels). + if isinstance(attachments, str): + attachments = [attachments] + + # Resolve every attachment path (relative paths are anchored at the project + # root, like _build_service / download_attachments) and fail early if any file + # is missing — the email is NOT sent unless every attachment is present. + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + resolved: list[str] = [] + total_bytes = 0 + for raw_path in attachments: + path = raw_path if os.path.isabs(raw_path) else os.path.join(project_root, raw_path) + if not os.path.isfile(path): + return f"Error: attachment not found: {raw_path}" + total_bytes += os.path.getsize(path) + resolved.append(path) + + if total_bytes > _MAX_ATTACHMENT_BYTES: + return ( + f"Error: attachments total ~{total_bytes // (1024 * 1024)} MB, which exceeds the " + f"{_MAX_ATTACHMENT_BYTES // (1024 * 1024)} MB send limit. Send fewer or smaller files." + ) + + # EmailMessage builds a plain text/plain message when there are no attachments + # and switches to multipart/mixed automatically once one is added. + msg = EmailMessage() + msg["To"] = to + if cc: + msg["Cc"] = cc + if bcc: + msg["Bcc"] = bcc + msg["Subject"] = subject + # RFC 2822 threading headers for in-thread reply. + if in_reply_to: + msg["In-Reply-To"] = f"<{in_reply_to}>" + msg["References"] = f"<{in_reply_to}>" + msg.set_content(body_text) + + for path in resolved: + ctype, encoding = mimetypes.guess_type(path) + # Fall back to a generic binary type for unknown or compressed files. + if ctype is None or encoding is not None: + ctype = "application/octet-stream" + maintype, subtype = ctype.split("/", 1) + try: + with open(path, "rb") as f: + data = f.read() + except Exception as e: + return f"Error: could not read attachment {path}: {e}" + msg.add_attachment(data, maintype=maintype, subtype=subtype, + filename=os.path.basename(path)) + + encoded = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8") + + # Build API body — include threadId when replying in-thread. + api_body: dict = {"raw": encoded} + if thread_id: + api_body["threadId"] = thread_id + + try: + sent = _call(lambda: svc.users().messages().send(userId="me", body=api_body).execute(), "Gmail") + except Exception as e: + return _format_google_error(e, "Gmail") + + suffix = f" ({len(resolved)} attachment{'s' if len(resolved) != 1 else ''})" if resolved else "" + return f"✅ Message sent! ID: {sent.get('id', '?')}{suffix}" + + +def _gmail_get_profile(args: dict) -> str: + """Get Gmail profile info (email address, total/thread count).""" + svc = _get_service() + if svc is None: + return f"Error: {_init_error}" + + try: + profile = _call(lambda: svc.users().getProfile(userId="me").execute(), "Gmail") + except Exception as e: + return _format_google_error(e, "Gmail") + + return ( + f"Email: {profile.get('emailAddress', '?')}\n" + f"Messages total: {profile.get('messagesTotal', '?')}\n" + f"Threads total: {profile.get('threadsTotal', '?')}\n" + f"History ID: {profile.get('historyId', '?')}" + ) + + +def _gmail_create_label(args: dict) -> str: + """Create a new Gmail label.""" + svc = _get_service() + if svc is None: + return f"Error: {_init_error}" + + name = args.get("name") + if not name: + return "Error: Missing required parameter 'name'." + + label_list_visibility = args.get("label_list_visibility", "labelShow") + message_list_visibility = args.get("message_list_visibility", "show") + + body = { + "name": name, + "labelListVisibility": label_list_visibility, + "messageListVisibility": message_list_visibility, + } + + try: + result = _call(lambda: svc.users().labels().create(userId="me", body=body).execute(), "Gmail") + except Exception as e: + return _format_google_error(e, "Gmail") + + return f"✅ Label '{result.get('name', name)}' created (ID: {result.get('id', '?')})" + + +def _gmail_download_attachments(args: dict) -> str: + """Download all attachments from a Gmail message to a local folder.""" + svc = _get_service() + if svc is None: + return f"Error: {_init_error}" + + msg_id = args.get("message_id") + if not msg_id: + return "Error: Missing required parameter 'message_id'." + + # Default to data/gmail_attachments/ (served via /data/... in the frontend, + # consistent with whatsapp_media). Allow override. + default_folder = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "data", "gmail_attachments", + ) + folder = args.get("folder") or default_folder + + try: + msg = _call(lambda: svc.users().messages().get(userId="me", id=msg_id, format="full").execute(), "Gmail") + except Exception as e: + return _format_google_error(e, "Gmail") + + payload = msg.get("payload", {}) + + attachments: list = [] + _collect_attachments(payload.get("parts", []), attachments) + + if not attachments: + return "No attachments found." + + os.makedirs(folder, exist_ok=True) + + saved = [] + for att in attachments: + filename = att["filename"] + attachment_id = att["attachmentId"] + + try: + result = _call(lambda a=att: svc.users().messages().attachments().get( + userId="me", messageId=msg_id, id=a["attachmentId"], + ).execute(), "Gmail") + except Exception as e: + saved.append(f"- {filename}: ERROR fetching attachment: {_http_error_reason(e)}") + continue + + data = result.get("data", "") + if not data: + saved.append(f"- {filename}: empty attachment data") + continue + + try: + file_data = base64.urlsafe_b64decode(data) + except Exception as e: + saved.append(f"- {filename}: ERROR decoding: {e}") + continue + + safe_name = os.path.basename(filename) + file_path = os.path.join(folder, safe_name) + + try: + with open(file_path, "wb") as f: + f.write(file_data) + except Exception as e: + saved.append(f"- {safe_name}: ERROR writing file: {e}") + continue + + abs_path = os.path.abspath(file_path) + size = len(file_data) + saved.append(f"- {abs_path} ({size} bytes)") + + return "\n".join(["✅ Attachments downloaded:"] + saved) + + +# ── Tool manifest ────────────────────────────────────────────────────────────── + +TOOLS = [ + { + "name": "status", + "description": ( + "Self-check that the Google Gmail integration is operational: verifies the OAuth " + "credentials load, the access token refreshes when needed, and the Gmail API responds, " + "by performing one cheap getProfile probe. Call this first whenever another gmail tool " + "fails, or to give the user a quick yes/no on whether Gmail is usable right now." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "list_messages", + "description": ( + "List Gmail messages with optional query and label filter. Returns summaries with " + "subject, sender, date, message ID and thread ID. Use Gmail search syntax in 'query' " + "(e.g. 'from:john', 'is:unread', 'after:2024/01/01', 'has:attachment'). Pass the " + "returned IDs to get_message / modify_message." + ), + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Gmail search query (e.g. 'from:john', 'is:unread', 'after:2024/01/01'). Leave empty for all recent messages.", + }, + "max_results": { + "type": "integer", + "description": "Max messages to return (default 20, max 50).", + }, + "label_ids": { + "type": ["string", "array"], + "items": {"type": "string"}, + "description": "Filter by label IDs (e.g. 'INBOX', or ['INBOX','STARRED']). Pass a single string or an array.", + }, + "page_token": { + "type": "string", + "description": "Opaque token from a previous response's 'More results available' line, to fetch the next page.", + }, + }, + }, + }, + { + "name": "get_message", + "description": ( + "Get full content of a Gmail message by ID, including body text (truncated at 10000 " + "chars). HTML-only emails are converted to readable text, and any attachment filenames " + "are listed (download them with download_attachments)." + ), + "inputSchema": { + "type": "object", + "properties": { + "message_id": { + "type": "string", + "description": "The Gmail message ID to retrieve.", + }, + "include_body": { + "type": "boolean", + "description": "Whether to include the full body text (default true).", + }, + }, + "required": ["message_id"], + }, + }, + { + "name": "get_thread", + "description": "Get all messages in a thread by thread ID, newest last.", + "inputSchema": { + "type": "object", + "properties": { + "thread_id": { + "type": "string", + "description": "The Gmail thread ID to retrieve.", + }, + }, + "required": ["thread_id"], + }, + }, + { + "name": "list_labels", + "description": "List all Gmail labels/folders/categories with total and unread message counts. Use to resolve label IDs for modify_message.", + "inputSchema": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "modify_message", + "description": ( + "Modify message labels: mark read, archive, star, etc. Use label IDs like 'UNREAD', " + "'STARRED', 'INBOX'. remove_labels=['UNREAD'] marks as read; remove_labels=['INBOX'] " + "archives. add_labels/remove_labels each accept a single string or an array." + ), + "inputSchema": { + "type": "object", + "properties": { + "message_id": { + "type": "string", + "description": "The Gmail message ID to modify.", + }, + "add_labels": { + "type": ["string", "array"], + "items": {"type": "string"}, + "description": "Label ID(s) to add (e.g. 'STARRED', or ['STARRED','IMPORTANT']).", + }, + "remove_labels": { + "type": ["string", "array"], + "items": {"type": "string"}, + "description": "Label ID(s) to remove (e.g. 'UNREAD' to mark as read, 'INBOX' to archive).", + }, + }, + "required": ["message_id"], + }, + }, + { + "name": "send_message", + "description": ( + "Send an email via Gmail. Supports in-thread replies via the optional in_reply_to " + "(message ID) and thread_id parameters. For a reply, pass both for correct threading " + "across email clients. Attach files by passing local file paths in 'attachments'; the " + "server reads them from disk and, if any path does not exist, the email is NOT sent and " + "an error is returned." + ), + "inputSchema": { + "type": "object", + "properties": { + "to": { + "type": "string", + "description": "Recipient email address.", + }, + "subject": { + "type": "string", + "description": "Email subject line.", + }, + "body": { + "type": "string", + "description": "Plain text body of the email.", + }, + "cc": { + "type": "string", + "description": "CC recipient email (optional).", + }, + "bcc": { + "type": "string", + "description": "BCC recipient email (optional).", + }, + "in_reply_to": { + "type": "string", + "description": "Message ID to reply to (adds In-Reply-To and References headers for proper threading).", + }, + "thread_id": { + "type": "string", + "description": "Thread ID to attach the reply to (ensures the message appears in the correct Gmail thread).", + }, + "attachments": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "File path(s) to attach. Each is absolute, or relative to the project root " + "(e.g. 'data/gmail_attachments/report.pdf'). The server reads each file from " + "disk; if any path does not exist the email is NOT sent and an error is " + "returned. Total size limit ~25 MB." + ), + }, + }, + "required": ["to", "subject", "body"], + }, + }, + { + "name": "get_profile", + "description": "Get Gmail profile info: email address, total message/thread count, current history ID.", + "inputSchema": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "create_label", + "description": "Create a new Gmail label/folder. Returns the new label ID. Fails if a label with the same name already exists.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the new label.", + }, + "label_list_visibility": { + "type": "string", + "description": "Visibility in the label list: 'labelShow' (default), 'labelShowIfUnread', 'labelHide'.", + "default": "labelShow", + }, + "message_list_visibility": { + "type": "string", + "description": "Visibility in the message list: 'show' (default) or 'hide'.", + "default": "show", + }, + }, + "required": ["name"], + }, + }, + { + "name": "download_attachments", + "description": ( + "Download all attachments from a Gmail message to a local folder. " + "Defaults to data/gmail_attachments/ (served via /data/... in the frontend). " + "Returns the absolute path and size of each saved file." + ), + "inputSchema": { + "type": "object", + "properties": { + "message_id": { + "type": "string", + "description": "The Gmail message ID to download attachments from.", + }, + "folder": { + "type": "string", + "description": "Local folder to save attachments into (default: data/gmail_attachments/).", + }, + }, + "required": ["message_id"], + }, + }, +] + + +# ── JSON-RPC dispatch ────────────────────────────────────────────────────────── + +TOOL_DISPATCH = { + "status": _gmail_status, + "list_messages": _gmail_list_messages, + "get_message": _gmail_get_message, + "get_thread": _gmail_get_thread, + "list_labels": _gmail_list_labels, + "modify_message": _gmail_modify_message, + "send_message": _gmail_send_message, + "get_profile": _gmail_get_profile, + "create_label": _gmail_create_label, + "download_attachments": _gmail_download_attachments, +} + + +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": "gmail", + "version": "0.2.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 Gmail MCP server") + # 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() diff --git a/connectors/gmail/gmail_oauth_setup.py b/connectors/gmail/gmail_oauth_setup.py new file mode 100644 index 0000000..2a06ae4 --- /dev/null +++ b/connectors/gmail/gmail_oauth_setup.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Generate a Google OAuth token for Gmail API. + +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/gmail_creds.json + +No manual copy-paste required. +""" + +from __future__ import annotations + +import json +import os +import sys + +SCOPES = [ + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.labels", +] + +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SECRET_PATH = os.path.join(_ROOT, "secrets", "gmail_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: + # Lazy-import so we can show helpful errors if not installed. + 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: pip3 install google-auth google-auth-oauthlib google-api-python-client") + sys.exit(1) + + creds = None + + # Try to load existing credentials first, in case they have refresh token. + 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 exist and are valid, we're good. + if creds and creds.valid: + print("Credentials are already valid!") + return + + # If creds exist but expired, try to refresh. + 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() + # Start OAuth flow using local server (opens browser automatically). + 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, # pick a random available port + open_browser=True, + prompt="consent", + access_type="offline", + ) + + # Save credentials. + 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✅ Gmail OAuth token saved to {SECRET_PATH}") + print(f" Scopes: {creds.scopes}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/connectors/gmail/icon_lg.svg b/connectors/gmail/icon_lg.svg new file mode 100644 index 0000000..ec8d17e --- /dev/null +++ b/connectors/gmail/icon_lg.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/connectors/gmail/icon_sm.svg b/connectors/gmail/icon_sm.svg new file mode 100644 index 0000000..7b84cf2 --- /dev/null +++ b/connectors/gmail/icon_sm.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/connectors/gmail/requirements.txt b/connectors/gmail/requirements.txt new file mode 100644 index 0000000..633433f --- /dev/null +++ b/connectors/gmail/requirements.txt @@ -0,0 +1,3 @@ +google-api-python-client>=2.150.0 +google-auth>=2.35.0 +google-auth-oauthlib>=1.2.0 diff --git a/connectors/index.html b/connectors/index.html new file mode 100644 index 0000000..875027b --- /dev/null +++ b/connectors/index.html @@ -0,0 +1,67 @@ + + + + + + Skald Connectors + + + +

🔌 Skald Connectors

+

Tested, ready-to-use connectors for Skald agents.

+
Loading...
+ + + + \ No newline at end of file diff --git a/connectors/tavily/connector.json b/connectors/tavily/connector.json new file mode 100644 index 0000000..5120008 --- /dev/null +++ b/connectors/tavily/connector.json @@ -0,0 +1,34 @@ +{ + "id": "tavily", + "name": "Tavily", + "version": "1.0.0", + "type": "mcp_remote", + "mcp_config": { + "url": "https://mcp.tavily.com/mcp/?tavilyApiKey={key}", + "transport": "streamable-http" + }, + "requires": [ + "API_KEY" + ], + "docs": [ + { + "lang": "en", + "description": "Tavily MCP: web search and content extraction server. Use for real-time search, news articles, and site scraping. Returns fresh, relevant results from the web.", + "llm_short_description": "Web search MCP \u2014 real-time results, news, site scraping. Richiede API key Tavily." + } + ], + "homepage": "https://tavily.com", + "icon_small": "icon_sm.png", + "icon_large": "icon_lg.png", + "scope": "global", + "tags": [ + "search", + "mcp", + "remote" + ], + "auth": { + "type": "api_key", + "delivery": "query", + "param": "tavilyApiKey" + } +} \ No newline at end of file diff --git a/connectors/tavily/icon_lg.png b/connectors/tavily/icon_lg.png new file mode 100644 index 0000000..b4f7acd Binary files /dev/null and b/connectors/tavily/icon_lg.png differ diff --git a/connectors/tavily/icon_sm.png b/connectors/tavily/icon_sm.png new file mode 100644 index 0000000..b4f7acd Binary files /dev/null and b/connectors/tavily/icon_sm.png differ