google-accounts-mcp

google-accounts-mcp

MCP server for multiple Google accounts, providing tools for Gmail, Drive, Calendar, Tasks, and Contacts with multi-account support and cross-MCP file handoff.

Category
访问服务器

README

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

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 under ATTACHMENTS_DIR. Path may be relative (e.g. local:uploads/report.pdf after upload_file) or reference a previously 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's notion_download_file(destination='shared'). Bare filenames only; verify staging with list_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_attachment is asked to write invoice.pdf into shared storage and a file with that name already exists, the new one lands at invoice-2.pdf (invoice-3.pdf, etc.). The create uses O_CREAT | O_EXCL so two concurrent writers never clobber each other, even without a prefix. The return value always reports the actual saved filename and the source='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 is invoice.pdf), the auto-rename is safe but ugly. Pass prefix=f"{message_id}_" to download_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 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.timer user 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 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 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 shared DRIVE_FOLDER_NAME folder). Although granted everywhere, 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 (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; was contacts.readonly) plus read-only access to the "Other contacts" autocomplete pool (Google offers no write 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 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 Resource objects are built on first 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 blocking httplib2 call would freeze every other request (the cause of the 4-minute "server unresponsive" stalls). mcp.tool is 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). Cached httplib2 objects aren't thread-safe, so _RetryingHttp serialises one account's socket with a per-instance RLock while letting other accounts run in parallel. Each call logs tool=… outcome=… duration_ms=… to stderr for your log pipeline.
  • Container healthcheckpython -m google_accounts_mcp.healthcheck does 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 managementrender_attachment_page and the OCR branch of extract_attachment_text close their pypdfium2 document/page/bitmap handles in try/finally (PDFium native memory isn't reclaimed deterministically by Python's GC). A threading.Semaphore caps concurrent rasterisation/parse — set PDF_MAX_CONCURRENCY (default 4) to tune. Run the container with MALLOC_ARENA_MAX=2 so 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, using hmac.compare_digest for constant-time comparison. /.well-known/* paths pass through so MCP clients don't confuse 401 for an OAuth-protected server.
  • Path-traversal guards_resolve_attachments rejects any path that resolves outside ATTACHMENTS_DIR, and upload_file / drive_upload require 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

MIT

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选