#!/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()