snickery

google-accounts-mcp

Community snickery
Updated

Multi-account Google MCP server: Gmail, Calendar, Drive, Tasks, and Contacts — every tool takes an account parameter

google-accounts-mcp

One MCP server for all your Googleaccounts — multi-account by design. 51 tools across six surfaces:Gmail, a shared Google Drive folder for file handoff, a cross-MCP sharedfilesystem, per-account Google Calendar (incl. calendar management),Google Tasks, and Google Contacts (read/write on saved contacts). Everytool takes an account parameter; authorize as many Google accounts asyou like and address them by name or unique substring. Built on thePython MCP SDK(FastMCP); runs as a local stdio server or a containerized StreamableHTTP service with bearer auth. Works with any MCP client.

Unofficial project, not affiliated with or endorsed by Google.

Table of Contents

  • Quick Start
  • Tool Reference
    • Account Management
    • Search & Read
    • Drafts & Sending
    • Labels & Lifecycle
    • Attachments
    • Drive File Store
    • Calendar
    • Tasks
    • Contacts
  • Authentication
  • Multi-Account Model
  • Configuration
  • Architecture
  • Development
  • Testing
  • Local stdio Mode
  • Container Deployment
  • MCP Client Registration

Quick Start

One-time Google Cloud setup: create (or pick) a GCP project, enable theGmail, Google Drive, Google Calendar, Google Tasks, and People APIs,and create an OAuth client ID of type Desktop app (APIs & Services →Credentials). That client ID/secret is what authorize.py uses for thelocal browser consent flow.

# Prerequisites: Python 3.12+, uv
uv sync

export GOOGLE_CLIENT_ID=...
export GOOGLE_CLIENT_SECRET=...
export TOKEN_DB_PATH=~/.local/share/google-accounts-mcp/tokens.db

# Authorize one or more Google accounts (opens a browser per account;
# stores refresh tokens in the SQLite DB, chmod 600)
uv run scripts/authorize.py [email protected] [email protected]

# Run over stdio (what most MCP clients spawn)
uv run google-accounts-mcp --stdio

# ...or as an HTTP server (requires MCP_BEARER_TOKEN)
MCP_BEARER_TOKEN=... uv run google-accounts-mcp
# Streamable HTTP at http://0.0.0.0:8321/mcp

Tool Reference

Every tool accepts an optional account parameter. When omitted it fallsback to DEFAULT_ACCOUNT (configured via env var). Partial account matchesare resolved automatically if unambiguous.

Account Management

Tool Parameters Description
list_accounts filter: str = "" List all authorized Gmail accounts. Optional substring filter.
list_labels account: str = "" List system and user labels for an account.

Search & Read

Tool Parameters Description
search_email query, account = "", max_results = 10, message_ids: list[str] | None = None Search with Gmail query syntax (e.g. is:unread from:[email protected]). When message_ids is supplied, the tool switches to pre-filter mode: it iterates the given IDs instead of calling Gmail's search API and keeps only the ones matching query. Currently only has:attachment is honored as a local predicate (other query terms are ignored in this mode). Useful when you already have a candidate set — e.g. exported from a Notion database — and want to find the subset with attachments without N blind round-trips.
read_email message_id, account = "" Fetch a full message: headers + text/plain body (falls back to stripped HTML).
read_thread thread_id, account = "" Fetch every message in a thread.

Drafts & Sending

Both draft_email and send_email take the same parameters:

Parameter Type Description
to list[str] Recipient addresses.
subject str Subject line.
body str Plain-text body.
account str Sending account. Defaults to DEFAULT_ACCOUNT.
cc list[str] CC recipients.
bcc list[str] BCC recipients.
reply_to_message_id str Turns the message into a reply — stamps In-Reply-To / References and sets threadId.
attachments list[str] Unified source references — see below.

Each attachments entry is a scheme:value string:

  • local:<path> — file under ATTACHMENTS_DIR. Path may be relative(e.g. local:uploads/report.pdf after upload_file) or reference apreviously downloaded attachment (local:<message_id>/<filename>).Paths outside ATTACHMENTS_DIR are rejected.
  • shared:<filename> — file on the cross-MCP shared mount (/shared),e.g. staged there by notion-mcp'snotion_download_file(destination='shared'). Bare filenames only;verify staging with list_shared_files.
  • drive:<name-or-id> — file in the shared Drivefolder. Tries name lookup first, falls back to treating the value as aDrive file ID; in either case the file must live inside the sharedfolder.
# Example
send_email(
    to=["[email protected]"],
    subject="Q2 report",
    body="See attached.",
    attachments=["local:uploads/q2.pdf", "drive:charts.xlsx"],
)

draft_email writes to the Drafts folder; send_email dispatches immediately(use with care).

Shared-Store Semantics

The shared mount at /shared (host path~/.local/share/containers/data/mcp-shared/) is read/write from bothMCP servers and has two invariants worth knowing:

  • Filename clashes auto-rename, atomically. If download_attachmentis asked to write invoice.pdf into shared storage and a file withthat name already exists, the new one lands at invoice-2.pdf(invoice-3.pdf, etc.). The create uses O_CREAT | O_EXCL so twoconcurrent writers never clobber each other, even without a prefix.The return value always reports the actual saved filename and thesource='shared:<actual-name>' string to pass to notion-mcp, so theagent never has to guess.
  • Explicit namespacing via prefix. In batch workflows wheremultiple messages may legitimately carry the same filename (e.g. 20different senders whose attachment is invoice.pdf), the auto-renameis safe but ugly. Pass prefix=f"{message_id}_" todownload_attachment and the files land as m1_invoice.pdf,m2_invoice.pdf, ... instead of invoice.pdf, invoice-2.pdf,invoice-3.pdf, which is much easier to reason about whencorrelating back to the source message.
  • 24h TTL. Files in shared storage are purged 24 hours after theirlast modification by the mcp-shared-purge.timer user unit on thehost. This is a safety net for forgotten handoffs, not a backup —stage to Notion (or rename out of the shared dir) within thatwindow. Agents that want immediate cleanup after a workflow cancall the purge_shared_files tool (see below).

Cross-MCP File Handoff to notion-mcp

google-accounts-mcp and notion-mcp share a volume (/shared in bothcontainers, host path ~/.local/share/containers/data/mcp-shared/) sofiles move between servers without base64-through-MCP — in bothdirections. Every transfer tool reports the sha256 of the bytes itmoved, so an agent can verify integrity end-to-end without shellaccess. The canonical pipeline for attaching a Gmail attachment to aNotion row:

download_attachment(
    message_id="19a1b2...",
    filename="invoice.pdf",
    destination="shared",              # writes SHARED_DIR/invoice.pdf
)
# Then from notion-mcp:
notion_add_file_to_row(
    page_id="...",
    source="shared:invoice.pdf",       # reads the same bytes
    files_property="Attachments",
)

And the reverse — emailing a file stored in Notion:

# From notion-mcp:
notion_download_file(
    block_id="...",                    # from notion_list_files_on_page
    destination="shared",              # writes SHARED_DIR/<name>
)
# Then from this server:
draft_email(
    to=["[email protected]"],
    subject="Contract",
    body="Attached.",
    attachments=["shared:contract.pdf"],
)

No size limit — the file bytes never traverse MCP parameters. Forcases where Drive persistence is also wanted, usedrive_upload(local_filename=...) instead of passing content_base64so the bytes stay on disk end-to-end.

Labels & Lifecycle

Tool Parameters Description
modify_labels message_id, add_labels: list[str] = [], remove_labels: list[str] = [], account = "" Add/remove label IDs.
archive_email message_id, account = "" Removes INBOX label.
mark_read message_id, account = "" Removes UNREAD.
mark_unread message_id, account = "" Adds UNREAD.
modify_labels("msg-id", add_labels=["STARRED"], remove_labels=["INBOX", "UNREAD"])

Attachments

Outgoing attachments must live under ATTACHMENTS_DIR — the server refusespaths outside it to prevent exfiltration. Use upload_file to stage a newfile or reference a previously downloaded attachment path.

Reading PDF attachments from a sandboxed agent. An MCP client runningin a sandbox VM typically cannot see ATTACHMENTS_DIR or SHARED_DIR onthis server, and return_base64=True on a multi-MB PDF blows past mostMCP clients' parameter-size ceilings. Use extract_attachment_text topull structured text (with a 200 KB response ceiling and a pages= rangeselector for anything bigger) and render_attachment_page for one-pagebitmaps when text extraction isn't enough.

Tool Parameters Description
upload_file filename, content_base64 Stages a file under ATTACHMENTS_DIR/uploads/. Filename must be bare (no path components). Collisions auto-rename (-2, -3, ...). Returns the ready-to-use local:uploads/<name> attachment reference and the sha256 of the saved bytes.
list_attachments message_id, account = "", exclude_inline = False Enumerate attachments on a message. Every entry carries a disposition hint (attachment vs inline). Set exclude_inline=True to skip embedded logos and tracking pixels — the default keeps them visible so you can still see what the email contains. Disposition is detected from the Content-Disposition header and falls back to Content-ID presence. When the same filename appears more than once (e.g. a vendor sends a receipt and an itemised invoice both named Invoice.pdf), each duplicate row is flagged with the 0-based index=N to pass to the fetch tools below.
batch_list_attachments message_ids: list[str], account = "", exclude_inline = False Batch variant of list_attachments — accepts a list of message IDs and returns a JSON map {id: [{filename, mime_type, size, disposition}, ...]}. Collapses N serial list_attachments calls into one when pre-filtering a candidate set (e.g. "of these 400 rows, which have real PDFs"). Per-message errors surface as a string value on the affected ID so one bad message doesn't poison the batch.
download_attachment message_id, filename, account = "", return_base64 = False, destination = "private", prefix = "", index = 0 destination='private' (default) saves under ATTACHMENTS_DIR/<message_id>/. destination='shared' saves under SHARED_DIR (the cross-MCP mount, /shared in the container) so notion-mcp can pick it up via source='shared:<filename>'. return_base64=True bypasses disk entirely; use only for small files. prefix is prepended to the saved filename for namespacing in parallel workflows — a common choice is prefix=f"{message_id}_" so two messages with invoice.pdf don't collide. Atomic create under the hood (O_EXCL) means concurrent callers are safe even without a prefix. index (0-based) selects among attachments sharing the same filename on one message — default 0 is the first match; out-of-range returns an error listing how many copies exist.
extract_attachment_text message_id, filename, account = "", pages: str | None = None, mode = "text", ocr = "auto", index = 0 Extract text from a PDF attachment server-side — returns JSON with {text, page_count, pages_returned, mode, truncated} (+ ocr_used/ocr_pages/ocr_engine when OCR ran). Designed for agents in a sandbox VM that can't reach SHARED_DIR or ATTACHMENTS_DIR and hit MCP parameter-size limits on return_base64=True. pages accepts pdftotext-style specs ("3", "1-5", "1,3,5", "1-3,7"). mode='text' is flowing text, 'layout' preserves columns (pdfplumber layout=True), 'tables' renders extract_tables() output as pipe-delimited markdown tables. Response-size ceiling is 200 KB — truncation happens on a page boundary with a trailing marker telling you which pages to fetch next. Non-PDF attachments are rejected. OCR (see PDF OCR): ocr='auto' (default) OCRs any requested page with < 20 chars of native text via RapidOCR; 'off' disables; 'force' OCRs every page; 'llm' transcribes pages with a vision model via the LiteLLM gateway. index (0-based) targets a specific copy when the filename is duplicated on the message.
render_attachment_page message_id, filename, page, account = "", max_width = 1200, format = "jpeg", quality = 75, return_base64 = False, index = 0 Render a single PDF page via pypdfium2 — no poppler dependency. Default return is a real MCP image content block, so the calling model sees the page directly (scans, charts, stamps — often no OCR needed at all). return_base64=True returns the legacy JSON {image_base64, mime_type, width, height, page, page_count} for programmatic relaying. One page per call bounds the payload. max_width is clamped 200..4000; JPEG at default width+quality lands around 100-200 KB. index (0-based) targets a specific copy when the filename is duplicated on the message.
list_shared_files filter: str = "" List files currently staged in the cross-MCP shared mount, with size and sha256 per file. Mirrors what notion-mcp sees via source='shared:<name>'. Useful to verify a handoff landed (and its integrity) before telling notion-mcp to attach it.
purge_file filename Delete a single bare-filename file from SHARED_DIR. Traversal-safe. Use after a successful handoff instead of waiting for the 24h TTL sweep (which would wipe unrelated in-flight work if you called purge_shared_files with max_age_hours=0).

PDF OCR

Scanned / image-only PDFs have no text layer, so native extraction returnsnothing. extract_attachment_text handles this with three reading tiers(implemented in src/google_accounts_mcp/pdf_read.py, duplicated verbatimin the sibling notion-mcp project — edit both copies together). OCR isdelegated to an xberg server — pages arerasterised locally (pypdfium2) and uploaded as PNGs in one multipartPOST /extract:

Tier Engine When
A — native text pdfplumber (local) Always first (except ocr='force'/'llm')
B — OCR xberg tesseract backend (paddle-ocr selectable via env but takes minutes/dense page on CPU — measured 2026-08-03) ocr='auto' on pages with < 20 chars of native text, or ocr='force'
C — VLM OCR xberg vlm backend → OpenAI-compatible vision endpoint (e.g. a LiteLLM gateway) ocr='llm' — handwriting, messy tables, low-quality scans

In ocr='auto', an OCR failure (xberg down, extraction error) never breaksextraction — the native-text result is returned with an ocr_error fieldinstead. ocr='force'/'llm' propagate the error.

Env (set per deployment):

Var Meaning Default
XBERG_BASE_URL xberg endpoint http://xberg:8000 (container DNS; point it at your xberg server)
XBERG_TIMEOUT Request timeout (s) 120
XBERG_OCR_BACKEND Classical backend for tier B tesseract
XBERG_VLM_MODEL Gateway model alias, passed verbatim cheap
XBERG_VLM_BASE_URL Vision endpoint, resolved by the xberg server http://litellm:4000/v1
XBERG_VLM_API_KEY Vision-endpoint API key, sent in the per-request vlm_config (unset — ocr='llm' errors with setup pointer)

The vlm key rides per-request because xberg 1.0.8 skips provider-env keyresolution whenever vlm_config.base_url is overridden (verified2026-08-03) — revisit server-side key placement if upstream fixes that.

For one-off visual questions, skip OCR entirely: render_attachment_pagereturns a real MCP image content block by default, so the calling modeljust looks at the page.

Drive File Store

A shared Google Drive folder (DRIVE_FOLDER_NAME on DRIVE_ACCOUNT) acts asa persistent file store for attachments that outlive a single server restart.Files uploaded via drive_upload can be passed to draft_email /send_email via the drive_attachments parameter — even from mailboxesother than the Drive-owning account.

Tool Parameters Description
drive_upload filename, content_base64 = "", local_filename = "" Upload to the shared folder. Returns file ID + webViewLink. Provide exactly one of content_base64 (raw base64, subject to MCP parameter size limits) or local_filename (reference an existing file — accepts <message_id>/<name> under ATTACHMENTS_DIR or a bare <name> under SHARED_DIR). local_filename is required for anything larger than ~20 KB because MCP parameter encoding truncates big base64 blobs.
purge_shared_files max_age_hours = 24.0, dry_run = False Delete files from SHARED_DIR older than max_age_hours. dry_run=True lists victims without deleting. Regular files only — subdirectories are left alone. Pair with a host-side cron/timer TTL sweep if you want automatic cleanup; use when an agent wants to clean up immediately after a workflow.
drive_list filter: str = "" List files in the folder. Filter matches filename substrings.
drive_download name_or_id, return_base64 = False Save to ATTACHMENTS_DIR/drive/ (collision auto-rename) or return inline base64. Returns sha256 and the local:drive/<name> attachment reference. Google-native files (Docs/Sheets) are refused — export first.

Calendar

Per-account Google Calendar read/write via the full calendar OAuthscope (since 2026-07-04; events + calendarList + calendar management). Every Gmail account hasits own calendar surface — the same account parameter used by Gmailtools also selects which calendar you operate on. Existing accountsmust re-run authorize.py after a scope change, since the OAuth consentis fixed at grant time (old refresh tokens return insufficientPermissionson calendar calls).

Why two scopes: calendar.events covers every events/* endpoint(list / get / insert / patch / delete / move / quickAdd / instances /freebusy), but calendarList is a separate surface with its own scope.Adding calendar.calendarlist.readonly is the narrowest way to givethe list_calendars tool what it needs — still strictly less permissivethan the full calendar scope (no ACL changes, no calendarcreate/delete, no settings).

Time-value model: a bare YYYY-MM-DD string makes an all-day event;anything else is treated as an RFC3339 dateTime (2026-04-14T15:00:00+10:00or 2026-04-14T15:00:00 + an explicit timezone IANA name). Thetimezone parameter is silently dropped for date-only values becauseGoogle Calendar rejects timeZone on all-day events. When your dateTimealready carries an offset, timezone is optional.

Tool Parameters Description
list_calendars account = "", filter = "" List calendars visible to the account (own + subscribed). Shows summary, access role, primary flag, and calendar ID.
list_events calendar_id = "primary", account = "", time_min = "", time_max = "", query = "", max_results = 25, single_events = True, show_deleted = False, order_by = "", page_token = "" List events on a calendar. time_min / time_max are RFC3339 timestamps. query is Google's free-text match on summary, description, location, attendees. single_events=True (the default) expands recurring events into individual instances and forces orderBy=startTime. Paginate via the next_page_token printed at the bottom of the result.
get_event event_id, calendar_id = "primary", account = "" Read one event's full detail — attendees + response status, description, recurrence rules, conference link (if any), organizer.
create_event summary, start, end, calendar_id = "primary", account = "", description = "", location = "", timezone = "", attendees: list[str] | None = None, recurrence: list[str] | None = None, send_updates = "none", reminders_minutes: list[int] | None = None Create a new event. attendees is a list of email addresses. recurrence is a list of RRULE/RDATE/EXDATE strings (e.g. ['RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR']). send_updates controls whether invite emails are dispatched ('all', 'externalOnly', 'none'). reminders_minutes overrides the default reminders with one popup per offset (e.g. [10, 60]).
update_event event_id, calendar_id = "primary", account = "", plus any of summary, start, end, description, location, timezone, attendees, recurrence, send_updates PATCH semantics — only fields explicitly set to a non-None value are sent. description="" clears the description; attendees=[] removes all attendees; recurrence=[] turns a recurring event into a one-off.
delete_event event_id, calendar_id = "primary", account = "", send_updates = "none" Delete an event. send_updates controls cancellation notifications.
quick_add_event text, calendar_id = "primary", account = "", send_updates = "none" Create an event from a natural-language phrase using Google's own parser (e.g. 'Dinner with Alice tomorrow 7pm'). Fast for simple events; use create_event for precise control.
move_event event_id, destination_calendar_id, source_calendar_id = "primary", account = "", send_updates = "none" Move an event from one calendar to another (both owned by the account).
respond_to_event event_id, response, calendar_id = "primary", account = "", comment = "", send_updates = "none" Set the account's RSVP. response accepts accepted / declined / tentative / needsAction and casual aliases (yes / no / maybe / accept / decline). If the account is not yet an attendee, it's appended as one.
list_instances event_id, calendar_id = "primary", account = "", time_min = "", time_max = "", max_results = 50 Expand a recurring event into its individual instances. Window with time_min / time_max.
create_calendar summary, account = "", description = "", timezone = "" Create a secondary calendar (e.g. 'Family'). Returns its ID for use as calendar_id.
update_calendar calendar_id, account = "", plus any of summary, description, timezone Rename a calendar / change metadata. PATCH semantics; description="" clears.
delete_calendar calendar_id, account = "" Permanently delete a SECONDARY calendar and all its events. The primary calendar is refused.
free_busy time_min, time_max, calendar_ids: list[str] | None = None, account = "", timezone = "" Query opaque busy-block intervals across one or more calendars (defaults to ['primary']). Returns per-calendar busy lists without event content — use for scheduling logic that doesn't need detail.
# Create a timed event with attendees and a popup reminder
create_event(
    summary="Architecture review",
    start="2026-04-15T10:00:00+10:00",
    end="2026-04-15T11:00:00+10:00",
    account="[email protected]",
    attendees=["[email protected]", "[email protected]"],
    reminders_minutes=[10],
    send_updates="all",
)

# Window query
list_events(
    account="[email protected]",
    time_min="2026-04-14T00:00:00+10:00",
    time_max="2026-04-15T00:00:00+10:00",
    query="standup",
)

# RSVP to an invite
respond_to_event(
    event_id="abc123",
    response="yes",
    account="[email protected]",
    comment="Running 5 min late",
)

Tasks

Per-account Google Tasks read/write via the tasks OAuth scope (the onlywrite scope Google offers for Tasks — there is no narrower option).task_list defaults to @default, the API alias for the account's defaultlist, so single-list users never need list_task_lists.

Due-date model: the Tasks API stores only a DATE — any time componentin an RFC3339 value is discarded server-side. Tools accept a bareYYYY-MM-DD and expand it to midnight UTC for the API.

Tool Parameters Description
list_task_lists account = "", filter = "" List the account's task lists (title + ID). Optional substring filter.
list_tasks task_list = "@default", account = "", show_completed = True, show_hidden = False, due_min = "", due_max = "", max_results = 50, page_token = "" List tasks. Completed tasks the user has cleared from the UI additionally need show_hidden=True. due_min/due_max window by due date but exclude tasks without one. Paginate via the printed next_page_token.
get_task task_id, task_list = "@default", account = "" One task's full detail — title, status, due, notes, parent, completion time.
create_task title, task_list = "@default", account = "", notes = "", due = "", parent = "", previous = "" Create a task. parent makes it a subtask; previous inserts after a sibling task ID (list ordering).
update_task task_id, task_list = "@default", account = "", plus any of title, notes, due, status PATCH semantics — only non-None fields are sent. notes="" clears notes; due="" clears the due date (sent as JSON null). status is 'completed' or 'needsAction' (reopening also clears the completion timestamp).
complete_task task_id, task_list = "@default", account = "" Mark completed — sugar for the most common mutation.
delete_task task_id, task_list = "@default", account = "" Permanently delete (vs. complete_task, which keeps it checked off).

Contacts

People API lookup + read/write on saved contacts (contacts +contacts.other.readonly scopes; write support added 2026-07-04). Thesecond scope covers Google's "Other contacts" pool (people the accounthas emailed but never saved — the Gmail autocomplete list), searched bydefault and tagged [other] in results. That pool is read-only at theAPI level; the supported write path is save_other_contact, whichcopies an entry into My Contacts where it becomes editable.

Search-cache warmup: Google's contact search reads from alazily-populated cache; the first search per account per process issues awarmup request and pauses ~2 s (per Google's documented guidance) beforethe real query. Subsequent searches are immediate.

Tool Parameters Description
search_contacts query, account = "", max_results = 10, include_other_contacts = True Prefix-match search over names, emails, phone numbers, and organizations, across saved + other contacts. The go-to tool for "what's Alice's address?".
list_contacts account = "", max_results = 50, page_token = "", sort_order = "LAST_MODIFIED_DESCENDING" Browse saved contacts. sort_order also accepts FIRST_NAME_ASCENDING / LAST_NAME_ASCENDING. Paginate via the printed next_page_token.
get_contact resource_name, account = "" Full detail (all emails, phones, org, addresses, birthday, notes) by people/c… or otherContacts/c… ID from search/list results. Other-contact IDs are transparently re-prefixed for the people.get endpoint.
# Resolve a name before drafting
search_contacts(query="alice", account="[email protected]")

# Capture a follow-up from an email thread
create_task(
    title="Reply to Alice re: contract",
    due="2026-07-07",
    notes="thread: <message-id>",
    account="[email protected]",
)

Authentication

Bearer auth (HTTP mode only): the HTTP server refuses to start withoutMCP_BEARER_TOKEN. Every request (except /.well-known/* discoveryprobes) must carry an Authorization: Bearer <token> header. Local stdiomode (--stdio) has no network surface and skips bearer auth entirely.

Google OAuth 2.0: each Gmail account is authorized once viascripts/authorize.py, which runs the installed-app OAuth flow and storesthe resulting refresh token in a SQLite DB (tokens.db). Access tokens arerefreshed on demand by the google-auth library — no background refresher.If you run both a container deployment and local stdio copies, rememberthe DB is a per-machine file: re-authorizing means copying the refreshedtokens.db to each deployment (and restarting the container so cachedservice objects drop stale credentials).

Scopes (authorize.py requests the full set on every account — a scopeaddition therefore requires a one-time re-auth of each existing account,since consent is fixed at grant time):

  • https://www.googleapis.com/auth/gmail.modify — read/write/label on allauthorized mailboxes.
  • https://www.googleapis.com/auth/drive.file — only files the app creates(i.e. the shared DRIVE_FOLDER_NAME folder). Although grantedeverywhere, the drive tools only ever operate against DRIVE_ACCOUNT.
  • https://www.googleapis.com/auth/calendar — full calendar scope(2026-07-04; replaced the narrower calendar.events +calendar.calendarlist.readonly pair when calendar management —create/update/delete calendar — was added; see Calendar).
  • https://www.googleapis.com/auth/tasks — Google Tasks read/write (nonarrower write scope exists).
  • https://www.googleapis.com/auth/contacts +https://www.googleapis.com/auth/contacts.other.readonly — read/writeon saved contacts (2026-07-04; was contacts.readonly) plus read-onlyaccess to the "Other contacts" autocomplete pool (Google offers nowrite scope for that pool — promote entries with save_other_contact).

Multi-Account Model

All tools accept account as an optional parameter. Resolution:

  1. Empty → DEFAULT_ACCOUNT if set, else the sole authorized account(a clear error lists the options when several exist).
  2. Exact match against stored accounts.
  3. Case-insensitive substring match — if unique, used; if ambiguous, raises.

After scripts/authorize.py completes you must restart the container so thein-memory service objects pick up the new credentials.

Configuration

All env vars are optional unless noted. Path defaults assume thecontainer layout (/data); set them explicitly for local runs.

Variable Default Description
MCP_BEARER_TOKEN (required in HTTP mode) Bearer token clients must present. The HTTP server refuses to start if unset; stdio mode doesn't use it.
GOOGLE_CLIENT_ID (required) OAuth 2.0 client ID.
GOOGLE_CLIENT_SECRET (required) OAuth 2.0 client secret.
TOKEN_DB_PATH /data/tokens.db SQLite DB holding per-account refresh tokens.
ATTACHMENTS_DIR /data/attachments Root for staged attachments and downloaded files.
DEFAULT_ACCOUNT (empty) Account used when a tool's account parameter is empty. Empty falls back to the sole authorized account.
DRIVE_ACCOUNT (empty) Account hosting the shared Drive folder. Required only for the drive_* tools.
DRIVE_FOLDER_NAME mcp-google-accounts Name of the shared Drive folder.
PORT 8321 HTTP port the server listens on.

Architecture

┌─────────────────┐   HTTP + Bearer    ┌──────────────────┐
│  Claude Code /  │ ─────────────────▶ │ google-accounts- │
│  VS Code / etc  │                    │  mcp (FastMCP)   │
└─────────────────┘                    └────────┬─────────┘
                                                │
                              ┌─────────────────┼─────────────────┐
                              ▼                 ▼                 ▼
                       ┌─────────────┐  ┌─────────────┐  ┌───────────────┐
                       │  Gmail API  │  │  Drive API  │  │  tokens.db    │
                       │  (per acct) │  │  (1 acct)   │  │  (SQLite)     │
                       └─────────────┘  └─────────────┘  └───────────────┘

Key design points:

  • SQLite token store (auth.py) — one row per account keyed by email,holding the OAuth refresh token. Opened fresh per query; no long-livedsqlite connection.
  • Lazy service cache — googleapiclient Resource objects are built onfirst use per account and cached in-memory for the life of the process.Credentials auto-refresh via AuthorizedHttp.
  • Threadpool tool offload (server.py) — the MCP SDK runs synchronous@mcp.tool() handlers inline on the event loop, so a single blockinghttplib2 call would freeze every other request (the cause of the4-minute "server unresponsive" stalls). mcp.tool is wrapped so each synctool is registered as an async wrapper that runs the body in a workerthread (anyio.to_thread.run_sync); the loop stays free for concurrent andcheap calls. The wrapper preserves the tool signature (so client schemasare unchanged — no restart needed) and returns the original sync functionas the module name (so tool-to-tool calls and tests still work). Cachedhttplib2 objects aren't thread-safe, so _RetryingHttp serialises oneaccount's socket with a per-instance RLock while letting other accountsrun in parallel. Each call logs tool=… outcome=… duration_ms=… to stderrfor your log pipeline.
  • Container healthcheckpython -m google_accounts_mcp.healthcheckdoes a full HTTP round-trip to /mcp; a wedged event loop fails the probeso a restart-on-unhealthy policy self-heals the container.
  • PDF resource managementrender_attachment_page and the OCR branch ofextract_attachment_text close their pypdfium2 document/page/bitmaphandles in try/finally (PDFium native memory isn't reclaimeddeterministically by Python's GC). A threading.Semaphore caps concurrentrasterisation/parse — set PDF_MAX_CONCURRENCY (default 4) to tune. Runthe container with MALLOC_ARENA_MAX=2 so glibc returns freed memory to theOS instead of retaining it in per-thread arenas. A 108-call mixedextract+render stress run across three accounts holds RSS flat (~320 MiB,well under the 1 GB cap) with zero restarts.
  • Pure ASGI bearer middleware — wraps the Streamable HTTP app(/mcp, stateless) and short-circuitsunauthenticated requests with a 401, using hmac.compare_digest forconstant-time comparison. /.well-known/* paths pass through so MCPclients don't confuse 401 for an OAuth-protected server.
  • Path-traversal guards_resolve_attachments rejects any path thatresolves outside ATTACHMENTS_DIR, and upload_file / drive_uploadrequire bare filenames with no path components.

Development

# Syntax check before building
python3 -c "import py_compile; py_compile.compile('src/google_accounts_mcp/server.py', doraise=True)"

# Build the container image
podman build -t google-accounts-mcp .   # or: docker build

The Streamable HTTP transport is stateless, so a rebuild never breaksclient sessions. If tool signatures changed, restart your MCP client so itre-fetches the schemas.

Testing

Three tiers:

# Tier 1 — pure helpers (no API, no mocking)
uv run --extra test pytest tests/test_gmail_helpers.py -v

# Tier 2 — Gmail client logic with mocked googleapiclient
uv run --extra test pytest tests/test_gmail_client.py -v

# Tier 1 + 2 — Calendar client (pure helpers + mocked calendar service)
uv run --extra test pytest tests/test_calendar_client.py -v

# Tier 1 + 2 — Tasks / Contacts clients (pure helpers + mocked services)
uv run --extra test pytest tests/test_tasks_client.py tests/test_contacts_client.py -v

# PDF reading / OCR tiers (pdf_read module + tool plumbing; the xberg
# HTTP calls are mocked — no network)
uv run --extra test pytest tests/test_pdf_read.py -v

# All unit tests together (fast, safe, run after every code change)
uv run --extra test pytest tests/test_gmail_helpers.py tests/test_gmail_client.py tests/test_calendar_client.py tests/test_tasks_client.py tests/test_contacts_client.py tests/test_pdf_read.py tests/test_retrying_http.py

# Tier 3 Gmail — live Gmail + Drive round-trip (gated)
[email protected] \
  TOKEN_DB_PATH=~/.local/share/google-accounts-mcp/tokens.db \
  ATTACHMENTS_DIR=~/.local/share/google-accounts-mcp/attachments \
  GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... \
  uv run --extra test pytest tests/test_integration.py -v

# Tier 3 Calendar — live Google Calendar round-trip (gated by the same
# env var). Every test creates its own event and deletes it in a finally
# block; nothing is left on the calendar on success. Events are scheduled
# 24+ hours out to stay off the visible week.
[email protected] \
  TOKEN_DB_PATH=~/.local/share/google-accounts-mcp/tokens.db \
  GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... \
  uv run --extra test pytest tests/test_calendar_integration.py -v

# Tier 3 Tasks — live Google Tasks round-trip in a dedicated scratch task
# list (created and deleted by the module). Tier 3 Contacts — read-only
# live smoke of search/list/get (nothing to clean up by construction).
[email protected] \
  TOKEN_DB_PATH=~/.local/share/google-accounts-mcp/tokens.db \
  GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... \
  uv run --extra test pytest tests/test_tasks_integration.py tests/test_contacts_integration.py -v

Integration tests create their own artifacts and clean up afterthemselves — drafts (never sent) and Drive files for the Gmail suite,test events for the Calendar suite, a scratch task list for the Taskssuite. Nothing is left behind on success.

Local stdio Mode

--stdio starts FastMCP's stdio transport: no uvicorn, no bearer token(the client owns the spawned process; there is no network surface). Thisis what most interactive MCP clients should use:

// e.g. Claude Desktop claude_desktop_config.json / Claude Code .mcp.json
{
  "mcpServers": {
    "google": {
      "command": "uv",
      "args": ["run", "--project", "/path/to/google-accounts-mcp",
               "google-accounts-mcp", "--stdio"],
      "env": {
        "GOOGLE_CLIENT_ID": "...",
        "GOOGLE_CLIENT_SECRET": "...",
        "TOKEN_DB_PATH": "/home/you/.local/share/google-accounts-mcp/tokens.db",
        "ATTACHMENTS_DIR": "/home/you/.local/share/google-accounts-mcp/attachments"
      }
    }
  }
}

Prefer an env file over inline values where your client supports it(uv run --env-file ...).

Container Deployment (HTTP)

podman build -t google-accounts-mcp .
podman run -d --name google-accounts-mcp -p 8321:8321 -v google-data:/data \
  -e GOOGLE_CLIENT_ID=... -e GOOGLE_CLIENT_SECRET=... \
  -e MCP_BEARER_TOKEN=some-long-random-token \
  google-accounts-mcp

The /data volume persists tokens.db and staged attachments acrossrestarts. Authorize accounts by running scripts/authorize.py on amachine with a browser and copying tokens.db into the volume (restartthe container afterwards). HTTP clients register the server as:

{
  "mcpServers": {
    "google": {
      "url": "https://your-host:8321/mcp",
      "headers": {"Authorization": "Bearer <MCP_BEARER_TOKEN>"}
    }
  }
}

Terminate TLS at a reverse proxy — the server itself speaks plain HTTP.Treat tokens.db like a password vault: whoever reads it controls everyconnected Google account across all granted scopes (it is created withmode 0600; keep the volume private).

License

MIT

MCP Server · Populars

MCP Server · New

    qcri

    🦡 codebadger

    🦡 codebadger is a containerized Model Context Protocol (MCP) server that gives AI agents and LLMs deep, queryable access to a codebase's structure and data flow through Joern Code Property Graphs (CPGs).

    Community qcri
    boringSQL

    DryRun PostgreSQL MCP

    PostgreSQL schema intelligence MCP server with offline linting, migration safety, query validation for AI coding assistants.

    Community boringSQL
    emerzon

    mtdata

    A MetaTrader 5 research toolkit with 90+ AI tools for forecasting, regime detection, pattern recognition, and live trading.

    Community emerzon
    OpenImageDebugger

    Open Image Debugger: Enabling visualization of in-memory buffers on GDB/LLDB

    An advanced in-memory image visualization plugin for GDB and LLDB on Linux, with experimental support for MacOS and Windows. Previously known as gdb-imagewatch. Also available as an extension for VSCode and forks

    Community OpenImageDebugger
    HQBase

    HQBase

    Your team's email workspace. In your Cloudflare account.

    Community HQBase