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 underATTACHMENTS_DIR. Path may be relative(e.g.local:uploads/report.pdfafterupload_file) or reference apreviously downloaded attachment (local:<message_id>/<filename>).Paths outsideATTACHMENTS_DIRare 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 withlist_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 writeinvoice.pdfinto shared storage and a file withthat name already exists, the new one lands atinvoice-2.pdf(invoice-3.pdf, etc.). The create usesO_CREAT | O_EXCLso 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 isinvoice.pdf), the auto-renameis safe but ugly. Passprefix=f"{message_id}_"todownload_attachmentand the files land asm1_invoice.pdf,m2_invoice.pdf, ... instead ofinvoice.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.timeruser 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 thepurge_shared_filestool (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 sharedDRIVE_FOLDER_NAMEfolder). Although grantedeverywhere, the drive tools only ever operate againstDRIVE_ACCOUNT.https://www.googleapis.com/auth/calendar— full calendar scope(2026-07-04; replaced the narrowercalendar.events+calendar.calendarlist.readonlypair 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; wascontacts.readonly) plus read-onlyaccess to the "Other contacts" autocomplete pool (Google offers nowrite scope for that pool — promote entries withsave_other_contact).
Multi-Account Model
All tools accept account as an optional parameter. Resolution:
- Empty →
DEFAULT_ACCOUNTif set, else the sole authorized account(a clear error lists the options when several exist). - Exact match against stored accounts.
- 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
Resourceobjects are built onfirst use per account and cached in-memory for the life of the process.Credentials auto-refresh viaAuthorizedHttp. - Threadpool tool offload (
server.py) — the MCP SDK runs synchronous@mcp.tool()handlers inline on the event loop, so a single blockinghttplib2call would freeze every other request (the cause of the4-minute "server unresponsive" stalls).mcp.toolis 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). Cachedhttplib2objects aren't thread-safe, so_RetryingHttpserialises oneaccount's socket with a per-instanceRLockwhile letting other accountsrun in parallel. Each call logstool=… outcome=… duration_ms=…to stderrfor your log pipeline. - Container healthcheck —
python -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 management —
render_attachment_pageand the OCR branch ofextract_attachment_textclose theirpypdfium2document/page/bitmaphandles intry/finally(PDFium native memory isn't reclaimeddeterministically by Python's GC). Athreading.Semaphorecaps concurrentrasterisation/parse — setPDF_MAX_CONCURRENCY(default4) to tune. Runthe container withMALLOC_ARENA_MAX=2so 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, usinghmac.compare_digestforconstant-time comparison./.well-known/*paths pass through so MCPclients don't confuse 401 for an OAuth-protected server. - Path-traversal guards —
_resolve_attachmentsrejects any path thatresolves outsideATTACHMENTS_DIR, andupload_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