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

    lyc403223157-source

    Knowledge Inbox

    Local-first knowledge ingestion for AI agents and Obsidian

    ipiton

    agent-memory-mcp

    MCP server that gives AI agents persistent memory with semantic search

    Community ipiton
    dialog-tools

    Dialog MCP Server

    Turn Reddit's chaos into structured insights with full citations. MCP server for competitive analysis, customer discovery, and market research. Zero-setup hosted solution with semantic search across 20,000+ subreddits.

    Community dialog-tools
    Bevel-Software

    hexis

    Git-backed skills, tools & context for AI agents

    Community Bevel-Software
    jonashertner

    OpenCaseLaw

    Open Swiss legal corpus + MCP server: 1M+ court decisions (1875–today), 21k laws, 10M-edge citation graph, 42 MCP tools. CC0 data, MIT code. Live at mcp.opencaselaw.ch

    Community jonashertner