google-accounts-mcp
One MCP server for all your Google
accounts — multi-account by design. 51 tools across six surfaces:
Gmail, a shared Google Drive folder for file handoff, a cross-MCP shared
filesystem, per-account Google Calendar (incl. calendar management),
Google Tasks, and Google Contacts (read/write on saved contacts). Every
tool takes an account parameter; authorize as many Google accounts as
you like and address them by name or unique substring. Built on the
Python MCP SDK
(FastMCP); runs as a local stdio server or a containerized Streamable
HTTP 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
- 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 the
Gmail, 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 the
local 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 you@example.com second@gmail.com
# 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 falls
back to DEFAULT_ACCOUNT (configured via env var). Partial account matches
are 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:alice@example.com). 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 a previously 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 Drive folder. Tries name lookup first, falls back to treating the value as a Drive file ID; in either case the file must live inside the shared folder.
# Example
send_email(
to=["alice@example.com"],
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 both
MCP servers and has two invariants worth knowing:
- Filename clashes auto-rename, atomically. If
download_attachmentis asked to writeinvoice.pdfinto shared storage and a file with that name already exists, the new one lands atinvoice-2.pdf(invoice-3.pdf, etc.). The create usesO_CREAT | O_EXCLso two concurrent 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 the agent never has to guess. - Explicit namespacing via
prefix. In batch workflows where multiple messages may legitimately carry the same filename (e.g. 20 different senders whose attachment isinvoice.pdf), the auto-rename is 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 when correlating back to the source message. - 24h TTL. Files in shared storage are purged 24 hours after their
last modification by the
mcp-shared-purge.timeruser unit on the host. This is a safety net for forgotten handoffs, not a backup — stage to Notion (or rename out of the shared dir) within that window. Agents that want immediate cleanup after a workflow can call thepurge_shared_filestool (see below).
Cross-MCP File Handoff to notion-mcp
google-accounts-mcp and notion-mcp share a volume (/shared in both
containers, host path ~/.local/share/containers/data/mcp-shared/) so
files move between servers without base64-through-MCP — in both
directions. Every transfer tool reports the sha256 of the bytes it
moved, so an agent can verify integrity end-to-end without shell
access. The canonical pipeline for attaching a Gmail attachment to a
Notion 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=["alice@example.com"],
subject="Contract",
body="Attached.",
attachments=["shared:contract.pdf"],
)
No size limit — the file bytes never traverse MCP parameters. For
cases where Drive persistence is also wanted, use
drive_upload(local_filename=...) instead of passing content_base64
so 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 refuses
paths outside it to prevent exfiltration. Use upload_file to stage a new
file or reference a previously downloaded attachment path.
Reading PDF attachments from a sandboxed agent. An MCP client running
in a sandbox VM typically cannot see ATTACHMENTS_DIR or SHARED_DIR on
this server, and return_base64=True on a multi-MB PDF blows past most
MCP clients' parameter-size ceilings. Use extract_attachment_text to
pull structured text (with a 200 KB response ceiling and a pages= range
selector for anything bigger) and render_attachment_page for one-page
bitmaps 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 returns
nothing. extract_attachment_text handles this with three reading tiers
(implemented in src/google_accounts_mcp/pdf_read.py, duplicated verbatim
in the sibling notion-mcp project — edit both copies together). OCR is
delegated to an xberg server — pages are
rasterised locally (pypdfium2) and uploaded as PNGs in one multipart
POST /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 breaks
extraction — the native-text result is returned with an ocr_error field
instead. 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 key
resolution whenever vlm_config.base_url is overridden (verified
2026-08-03) — revisit server-side key placement if upstream fixes that.
For one-off visual questions, skip OCR entirely: render_attachment_page
returns a real MCP image content block by default, so the calling model
just looks at the page.
Drive File Store
A shared Google Drive folder (DRIVE_FOLDER_NAME on DRIVE_ACCOUNT) acts as
a 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 mailboxes
other 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 OAuth
scope (since 2026-07-04; events + calendarList + calendar management). Every Gmail account has
its own calendar surface — the same account parameter used by Gmail
tools also selects which calendar you operate on. Existing accounts
must re-run authorize.py after a scope change, since the OAuth consent
is fixed at grant time (old refresh tokens return insufficientPermissions
on 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 give
the list_calendars tool what it needs — still strictly less permissive
than the full calendar scope (no ACL changes, no calendar
create/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:00
or 2026-04-14T15:00:00 + an explicit timezone IANA name). The
timezone parameter is silently dropped for date-only values because
Google Calendar rejects timeZone on all-day events. When your dateTime
already 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="alice@example.com",
attendees=["alice@example.com", "bob@example.com"],
reminders_minutes=[10],
send_updates="all",
)
# Window query
list_events(
account="alice@example.com",
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="alice@example.com",
comment="Running 5 min late",
)
Tasks
Per-account Google Tasks read/write via the tasks OAuth scope (the only
write scope Google offers for Tasks — there is no narrower option).
task_list defaults to @default, the API alias for the account's default
list, so single-list users never need list_task_lists.
Due-date model: the Tasks API stores only a DATE — any time component
in an RFC3339 value is discarded server-side. Tools accept a bare
YYYY-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). The
second scope covers Google's "Other contacts" pool (people the account
has emailed but never saved — the Gmail autocomplete list), searched by
default and tagged [other] in results. That pool is read-only at the
API level; the supported write path is save_other_contact, which
copies an entry into My Contacts where it becomes editable.
Search-cache warmup: Google's contact search reads from a lazily-populated cache; the first search per account per process issues a warmup request and pauses ~2 s (per Google's documented guidance) before the 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="alice@example.com")
# 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="alice@example.com",
)
Authentication
Bearer auth (HTTP mode only): the HTTP server refuses to start without
MCP_BEARER_TOKEN. Every request (except /.well-known/* discovery
probes) must carry an Authorization: Bearer <token> header. Local stdio
mode (--stdio) has no network surface and skips bearer auth entirely.
Google OAuth 2.0: each Gmail account is authorized once via
scripts/authorize.py, which runs the installed-app OAuth flow and stores
the resulting refresh token in a SQLite DB (tokens.db). Access tokens are
refreshed on demand by the google-auth library — no background refresher.
If you run both a container deployment and local stdio copies, remember
the DB is a per-machine file: re-authorizing means copying the refreshed
tokens.db to each deployment (and restarting the container so cached
service objects drop stale credentials).
Scopes (authorize.py requests the full set on every account — a scope
addition 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 all authorized mailboxes.https://www.googleapis.com/auth/drive.file— only files the app creates (i.e. the sharedDRIVE_FOLDER_NAMEfolder). Although granted everywhere, 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 (no narrower write scope exists).https://www.googleapis.com/auth/contacts+https://www.googleapis.com/auth/contacts.other.readonly— read/write on saved contacts (2026-07-04; wascontacts.readonly) plus read-only access to the "Other contacts" autocomplete pool (Google offers no write 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 the
in-memory service objects pick up the new credentials.
Configuration
All env vars are optional unless noted. Path defaults assume the
container 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-lived sqlite connection. - Lazy service cache — googleapiclient
Resourceobjects are built on first 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 the 4-minute "server unresponsive" stalls).mcp.toolis wrapped so each sync tool is registered as an async wrapper that runs the body in a worker thread (anyio.to_thread.run_sync); the loop stays free for concurrent and cheap calls. The wrapper preserves the tool signature (so client schemas are unchanged — no restart needed) and returns the original sync function as the module name (so tool-to-tool calls and tests still work). Cachedhttplib2objects aren't thread-safe, so_RetryingHttpserialises one account's socket with a per-instanceRLockwhile letting other accounts run in parallel. Each call logstool=… outcome=… duration_ms=…to stderr for your log pipeline. - Container healthcheck —
python -m google_accounts_mcp.healthcheckdoes a full HTTP round-trip to/mcp; a wedged event loop fails the probe so a restart-on-unhealthy policy self-heals the container. - PDF resource management —
render_attachment_pageand the OCR branch ofextract_attachment_textclose theirpypdfium2document/page/bitmap handles intry/finally(PDFium native memory isn't reclaimed deterministically by Python's GC). Athreading.Semaphorecaps concurrent rasterisation/parse — setPDF_MAX_CONCURRENCY(default4) to tune. Run the container withMALLOC_ARENA_MAX=2so glibc returns freed memory to the OS instead of retaining it in per-thread arenas. A 108-call mixed extract+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-circuits unauthenticated requests with a 401, usinghmac.compare_digestfor constant-time comparison./.well-known/*paths pass through so MCP clients don't confuse 401 for an OAuth-protected server. - Path-traversal guards —
_resolve_attachmentsrejects any path that resolves 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 breaks client sessions. If tool signatures changed, restart your MCP client so it re-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)
GMAIL_TEST_ACCOUNT=you@example.com \
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.
GMAIL_TEST_ACCOUNT=you@example.com \
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).
GMAIL_TEST_ACCOUNT=you@example.com \
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 after themselves — drafts (never sent) and Drive files for the Gmail suite, test events for the Calendar suite, a scratch task list for the Tasks suite. 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). This
is 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 across
restarts. Authorize accounts by running scripts/authorize.py on a
machine with a browser and copying tokens.db into the volume (restart
the 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 every
connected Google account across all granted scopes (it is created with
mode 0600; keep the volume private).
License
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file google_accounts_mcp-0.1.0.tar.gz.
File metadata
- Download URL: google_accounts_mcp-0.1.0.tar.gz
- Upload date:
- Size: 226.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8b5329f3856fe1b995718a9c5b30684d2e4278eaffdb43a597b7e5a50f722ddc
|
|
| MD5 |
f81dde9ab285a2e34e780ccae6e48fca
|
|
| BLAKE2b-256 |
77de8588161080c57e87f463cea56ce376e95ecc74281f50e9aa5a7a40b539a4
|
File details
Details for the file google_accounts_mcp-0.1.0-py3-none-any.whl.
File metadata
- Download URL: google_accounts_mcp-0.1.0-py3-none-any.whl
- Upload date:
- Size: 74.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e483de8ce501bd06ef9c9856aab03d179d55bd1859b41f101fa2c75eaebb2463
|
|
| MD5 |
169845bbf299883360d77a7186795ca3
|
|
| BLAKE2b-256 |
3780b2dc84284ec8df3a59e03c2fd56d32f724bc7c4528542b9af9cad7cb0506
|