Skip to content

Multi-Google MCP Server Implementation Plan - #1

Merged
MrBenJ merged 26 commits into
mainfrom
feat/multi-google-mcp-server-implementation-p
May 19, 2026
Merged

Multi-Google MCP Server Implementation Plan#1
MrBenJ merged 26 commits into
mainfrom
feat/multi-google-mcp-server-implementation-p

Conversation

@MrBenJ

@MrBenJ MrBenJ commented May 19, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds a local stdio MCP server (multi-google-mcp) that lets an MCP client operate across multiple Google accounts with read+write Gmail, Calendar, and Drive scopes, routing each tool call by an explicit account label.
  • Ships a standalone OAuth CLI (multi-google-mcp-auth add|list|remove) that runs Google's InstalledAppFlow outside the server, persisting per-label tokens at ~/.config/multi-google-mcp/accounts/<label>.json (chmod 600) with refresh-write-back semantics.
  • Exposes 17 tools (1 discovery + 4 Gmail + 6 Calendar + 6 Drive) wired through a single TOOL_REGISTRY so list/call stay in sync; pure shaping helpers keep tool layers thin and unit-testable.
  • README walks a new user from zero-GCP through Claude Desktop wiring; opt-in scripts/e2e_smoke.py boots the real server over MCP stdio and round-trips every domain against a live test account.

Test plan

  • uv run pytest — 55 unit tests pass (config/exceptions/accounts/auth_cli/shaping{gmail,calendar,drive}/tools{gmail,calendar,drive}/server)
  • uv run ruff check . — clean
  • uv run mypy — clean under `strict = true`
  • uv run multi-google-mcp-auth --help — argparse help renders, all three subcommands listed
  • Manual (requires GCP setup): MCP_E2E_ACCOUNT=<test-label> uv run python scripts/e2e_smoke.py — drives every tool surface against a real Google test account over stdio

🤖 Generated with Claude Code

MrBenJ and others added 18 commits May 18, 2026 22:50
Add pyproject.toml with mcp/google-api-python-client/google-auth deps,
ruff + mypy + pytest config, src/multi_google_mcp package skeleton, and
.gitignore for Python and per-account token storage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CONFIG_DIR under ~/.config/multi-google-mcp; SCOPES covers gmail.modify,
calendar, drive, and userinfo.email so we can label tokens by gmail
address.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AccountNotConfigured / AccountNeedsReauth carry the label so the MCP
tool layer can interpolate it into actionable messages. The base class
exists so callers can catch our errors as a group.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-account JSON token files live under ACCOUNTS_DIR keyed by label.
save() chmod 600 so other users on the machine can't read tokens.
conftest's tmp_config_dir fixture redirects all paths to tmp_path so
account tests don't touch the user's real ~/.config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
credentials() builds a google.oauth2.credentials.Credentials from the
on-disk token file plus the user-supplied client_secret.json. The
refresh_if_needed path catches invalid_grant and surfaces it as
AccountNeedsReauth so MCP tool callers get an actionable message
instead of a raw google-auth exception. _on_refresh persists the
new access token + expiry back to disk with chmod 600.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
multi-google-mcp-auth add <label> opens a browser, runs Google's
InstalledAppFlow on localhost, captures the credentials, looks up the
authenticated email via the oauth2 userinfo endpoint, and persists the
result under accounts/<label>.json so MCP tool callers can route by
label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The list/remove handlers landed in the Task 7 commit alongside add;
this commit adds explicit coverage so future edits don't silently
break the simpler paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
extract_body_text walks the multipart tree preferring text/plain;
falls back to stripping HTML when only text/html is present. The
shape_message_full helper folds attachments into a compact list so
MCP callers don't need to crawl the raw payload tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shape_event collapses Google's start/end objects to a single string
(prefers dateTime, falls back to date for all-day events) and folds
attendees to a {email, response} list. Optional fields (location,
description, attendees) are only emitted when present so MCP responses
stay compact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Google-native files (Docs, Sheets, Slides) can't be downloaded
directly — they have to be exported. export_mime_for centralises
that decision so the Drive tool can ask Drive to export Docs/Slides
as text/plain and Sheets as CSV. Binary files (PDF, images, etc.)
return None and the caller downloads them as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
saved_account drops a label on disk with a fake client_secret so
tool tests don't have to repeat the boilerplate. mock_build patches
googleapiclient.discovery.build inside each tool module that's been
imported so far — guarded with try/except ImportError so the fixture
works even when only some tools modules exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Search lists message IDs then fetches metadata per ID so the caller
gets shaped summaries in a single tool call. Send builds an
RFC822-compliant message via EmailMessage (handles cc/bcc, html
alternative, and threading via In-Reply-To/References) and uploads as
base64url under "raw". modify_labels routes to trash when the trash
flag is set so callers don't need to think about which Gmail endpoint
matches their intent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The modify_labels test asserts the body shape Gmail expects
(addLabelIds/removeLabelIds), and the trash test confirms we route
to messages().trash() instead of messages().modify() so the message
ends up in the Trash folder, not just unlabelled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…endars

list_events forces singleEvents=True + orderBy=startTime so callers
get expanded recurring instances in time order without thinking about
recurrence semantics. _time_node accepts either YYYY-MM-DD (all-day)
or full RFC3339 datetimes and routes to Google's date vs dateTime
field accordingly. update_event uses PATCH so unsupplied fields are
left alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
drive_read_file decides between export (Google-native types) and
get_media (binary) so the caller sees text content when Drive can give
it cleanly and base64 only when it can't. _media keeps the encoding
contract symmetric with the read tool: text-like mimes are sent as
UTF-8, anything else is decoded from base64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TOOL_REGISTRY is the one place that lists every tool: name,
description, JSON schema, and a handler thunk. build_app() wires the
registry into mcp.server.Server so list_tools and call_tool stay in
sync automatically. main() runs the stdio transport so the package's
console_script (multi-google-mcp) is a drop-in for Claude Desktop's
mcpServers config.

MultiGoogleMcpError is caught at the call_tool boundary and rendered
as a text response — the agent sees the actionable "run multi-google-
mcp-auth add work" message instead of a raw Python exception.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Opt-in (MCP_E2E_ACCOUNT env var) since it makes real network calls
into Google. Each domain's flow round-trips through every write tool
(send + trash, create + delete, upload + delete) so a successful run
proves both Levels: stdio transport works AND every tool reaches a
live Google API and back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README walks an outside human from "no GCP project" through enabling
the three APIs, configuring the OAuth consent screen, downloading
client_secret.json, installing the package, adding accounts, and
pasting the MCP server entry into Claude Desktop's config. Ends with
the opt-in e2e smoke script for verifying a real end-to-end setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@aria-teknal-studio aria-teknal-studio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings:

  1. [High] Account labels are used as filesystem paths without validation.
    In src/multi_google_mcp/accounts.py:33, _path() builds ACCOUNTS_DIR / f"{label}.json" directly from a caller-controlled label. That label comes from both the auth CLI and every MCP tool's account argument. Labels such as ../client_secret or ../../some/path can escape accounts/; save() can overwrite JSON files outside the account store, remove() can unlink them, and credentials()/refresh paths can read/write unexpected files. Because this is an MCP surface exposed to an LLM client, account labels should be treated as untrusted input. Please constrain labels to a safe slug format, reject separators/dots, and/or resolve the path and assert it remains inside ACCOUNTS_DIR. Add tests for traversal attempts on save, remove, and credentials.

  2. [Medium] Drive reads can return unbounded file contents through stdio.
    src/multi_google_mcp/tools/drive.py:44 reads the entire Drive object/export into memory, and src/multi_google_mcp/tools/drive.py:59 base64-encodes arbitrary binary content into the MCP JSON response. There is no size check against Drive metadata, no byte cap, and no pagination/streaming strategy. A large Drive file can hang or crash the local MCP server/client and flood the model context. The same pattern exists for uploads/updates accepting arbitrary content in src/multi_google_mcp/tools/drive.py:85 and src/multi_google_mcp/tools/drive.py:106. Please add explicit maximum payload sizes, reject or summarize oversized files before download, and test the limit behavior.

  3. [Medium] Token refresh writes are not atomic or protected against concurrent writers.
    src/multi_google_mcp/accounts.py:132 reads the token JSON, mutates it, and rewrites it with path.write_text(). If the auth CLI and server, or two server instances, refresh/write the same account at the same time, the account file can be truncated or corrupted. For a long-running local server this is a lifecycle bug: one bad interleaving can break all future calls for that account. Please use an atomic temp-file + replace flow and a per-account lock, and add a regression test around concurrent refresh/write behavior.

  4. [Medium] Most tool failures escape the MCP error handling path.
    src/multi_google_mcp/server.py:316 only catches MultiGoogleMcpError. Google API HttpError, invalid base64 in Drive upload/update, malformed account JSON, missing keys, and unexpected extra arguments all bubble out of call_tool. That leaves clients with transport-level/internal errors instead of a stable tool result, and it may make one bad model/tool call look like a server failure. Please catch and normalize expected operational errors at the MCP boundary while preserving enough detail for local debugging. Tests should cover at least a Google API failure and invalid Drive upload content.

  5. [Low] The risky surface has no GitHub CI coverage.
    GitHub reports no checks on this PR, and I do not see a workflow under .github/. Local verification passes for me: uv run pytest (55 passed), uv run ruff check ., and uv run mypy. Given this PR introduces Gmail/Calendar/Drive write tools and token handling, CI should run the unit gates at minimum on PRs before this lands. The live E2E can remain opt-in, but the pure unit/lint/typecheck coverage should be enforced.

MrBenJ and others added 5 commits May 18, 2026 23:22
Addresses Aria review finding #1. Account labels reach _path() from
both the auth CLI and every MCP tool's account argument, so they're
untrusted input. Labels like "../etc/passwd" could let save()
overwrite, credentials() read, and remove() unlink arbitrary files
outside ACCOUNTS_DIR.

_validate_label enforces a strict slug [A-Za-z0-9_-]{1,64}; called
from _path() so every code path goes through it. Adds
InvalidAccountLabel exception (inherits MultiGoogleMcpError so the MCP
boundary surfaces it as a clean tool error) and parametrised tests
covering save/credentials/remove with malicious inputs.

Also adds DriveFileTooLarge exception for the upcoming size-cap fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Aria review finding #2. A large Drive file could hang the
MCP transport or flood the model context. Added config.MAX_DRIVE_BYTES
(10 MiB) and DriveFileTooLarge guard on three paths:

- Binary read: checks metadata size BEFORE downloading so we never
  pull oversized bytes into memory.
- Google-native export (Docs/Sheets/Slides): metadata.size is always 0
  for native files, so we check after export but before encoding into
  the JSON response.
- Upload/update: checks the decoded payload before handing off to
  Google's MediaIoBaseUpload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Aria review finding #3. save() and _on_refresh() previously
did read-modify-write on the live token file, so a concurrent refresh
between two MCP server instances or the auth CLI could truncate or
corrupt the JSON.

_atomic_write_json writes to <path>.tmp.<random>, fsyncs, then
os.replace()s to the live path — atomic on POSIX. On exception the
tmp file is cleaned up. _file_lock acquires an exclusive fcntl.flock
on a sidecar <path>.lock around the RMW so two writers serialise.

Tests cover three failure modes: no temp leftovers after save, no
temp leftovers after _on_refresh, and original-token preservation
when os.replace fails mid-flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Aria review finding #4. Previously only MultiGoogleMcpError
was caught in call_tool, so Google HttpError, ValueError from base64
decode, KeyError on missing args, and TypeError on extra kwargs all
escaped as MCP transport errors instead of stable tool results.

Extracted _invoke_tool as a module-level function so tests can drive
it directly. Catches the four operational categories — typed
MultiGoogleMcpError, Google HttpError, malformed args, and a
catch-all for everything else — and renders each as "error: ..."
text. The decorator-wrapped call_tool inside build_app is now a
one-line delegation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Aria review finding #5. The risky surface (Gmail/Calendar/
Drive write tools, token handling) had no PR gate beyond local
verification. This workflow runs the same gates on every push to main
and every PR: ruff, mypy, pytest. Python 3.11 to match the floor in
pyproject; uv for dep install consistent with the README's install
path.

E2E smoke stays opt-in (no live credentials in CI by design).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@MrBenJ

MrBenJ commented May 19, 2026

Copy link
Copy Markdown
Owner Author

Addressed all 5 findings (commits 2bf026e..8194698, 85 unit tests all green).

  1. [High] Label path traversal — Fixed in 2bf026e. Added InvalidAccountLabel exception and _validate_label enforcing [A-Za-z0-9_-]{1,64} slug at the _path() chokepoint, so save/credentials/remove all reject ../escape, subdir/leak, .hidden, etc. Parametrised tests in tests/test_accounts.py.

  2. [Medium] Drive unbounded payloads — Fixed in 9375f1d. Added config.MAX_DRIVE_BYTES = 10 MiB + DriveFileTooLarge exception. Binary reads check metadata size before downloading; Google-native exports check after fetch but before encoding (size=0 in metadata is unavoidable for native files); uploads/updates check the decoded payload before handing to MediaIoBaseUpload.

  3. [Medium] Non-atomic token writes — Fixed in 1ce064e. _atomic_write_json writes to <path>.tmp.<random>, fsyncs, then os.replace(). _file_lock acquires an exclusive fcntl.flock on a sidecar .lock around the RMW in save() and _on_refresh(). Tests cover no-leftovers and original-token preservation when os.replace fails.

  4. [Medium] MCP error handling too narrow — Fixed in 20a20ba. Extracted _invoke_tool as a module-level function; catches MultiGoogleMcpError, googleapiclient.errors.HttpError, (ValueError, TypeError, KeyError), and a fallthrough for unexpected exceptions. Each returns "error: ..." text so the transport never sees a raw Python exception. Tests cover HttpError, ValueError, TypeError-for-unexpected-kwarg.

  5. [Low] No CI — Fixed in 8194698. .github/workflows/ci.yml runs uv run ruff check ., uv run mypy, uv run pytest on every push to main and every PR. Python 3.11 (the pyproject floor); uv for install. The opt-in E2E smoke stays opt-in (no live Google creds in CI).

Ready for re-review.

@aria-teknal-studio aria-teknal-studio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings:

  1. High - OAuth refresh tokens are briefly written to a temp file with default umask permissions before chmod 600.
    File: src/multi_google_mcp/accounts.py:48
    _atomic_write_json() opens the temp token file with plain open(tmp_path, "w"), writes refresh_token/access_token, fsyncs, closes it, and only then chmods it to 0600 at line 52. On a normal umask like 022, the temp file is readable by other local users during the write window, and the accounts directory is created with default directory permissions. Because this file contains Google refresh tokens for Gmail/Calendar/Drive, this is a real secret exposure. Open the temp file with restrictive permissions from creation time, e.g. os.open(..., O_CREAT|O_EXCL|O_WRONLY, 0o600) + fdopen, or set a restrictive umask around creation. Add a regression test that forces a permissive umask and observes the temp file mode before os.replace/chmod.

  2. Medium - The Drive native-file size cap still downloads the whole export into memory before enforcing the cap.
    File: src/multi_google_mcp/tools/drive.py:58
    For Google Docs/Sheets/Slides, drive_read_file() calls export(...).execute() and only checks len(raw_bytes) afterward at line 61. A large shared Sheet/Doc can still allocate the full export and block or kill the MCP server before DriveFileTooLarge is raised, so the cap does not actually protect this path from resource exhaustion. Use a chunked MediaIoBaseDownload/export stream and abort once accumulated bytes exceed MAX_DRIVE_BYTES, then test that oversized native exports stop before buffering the whole payload.

  3. Medium - Gmail full-message reads have no response-size guard.
    File: src/multi_google_mcp/tools/gmail.py:45
    gmail_get_message() fetches format="full" and shape_message_full() returns body_text without any cap (src/multi_google_mcp/shaping/gmail.py:71). Gmail messages can carry very large plain/html bodies, and this MCP server returns the decoded body directly into the stdio/model context. That creates the same resource-exhaustion/context-flooding risk the PR already mitigates for Drive. Add a max Gmail body/response size policy, use fields/format choices where possible, and cover oversized message bodies in tests.

Verification:

  • uv run ruff check . passed.
  • uv run mypy passed.
  • uv run pytest passed: 85 tests.
  • GitHub PR check "pytest + ruff + mypy" is passing.

Summary:
This is a strong first implementation and CI now covers the main local gates, but the remaining issues are on security-sensitive token persistence and unbounded external payload paths. I would not merge until those are addressed.

MrBenJ and others added 3 commits May 18, 2026 23:32
Addresses Aria iter-2 finding #1. _atomic_write_json previously called
plain open(tmp_path, "w") which respects umask — under the common
umask 0o022 that creates the file as 0o644 (world-readable) for the
window between open and chmod 0o600. Since the temp file contains the
refresh token, that window is a real secret exposure for other local
users.

Switched to os.open with O_CREAT|O_EXCL|O_WRONLY and mode 0o600 so the
file is never readable by anyone but the owner, even briefly. Kept
the chmod 0o600 as belt-and-braces in case some platform ignores the
open mode (FreeBSD with some mount flags, etc.).

Regression test forces umask 0o022 and patches os.chmod to a no-op so
the only way to land at 0o600 in the captured mode is for open() to
have created it that way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Aria iter-2 finding #2. Native files (Docs/Sheets/Slides)
always report size=0 in metadata, so the previous fix had to check
length after calling export().execute() — which buffered the entire
export into memory regardless, defeating the cap as a defence against
resource exhaustion.

_download_chunked wraps the export request in MediaIoBaseDownload
with 1 MiB chunks and watches buf.tell() between next_chunk() calls.
For a 500 MiB shared Sheet the abort fires after ~11 chunks instead
of waiting for the whole payload.

Regression test feeds 1 MiB chunks indefinitely (next_chunk never
reports done) and asserts the abort lands well below 50 iterations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Aria iter-2 finding #3. gmail_get_message returned the
decoded body verbatim, so a multi-MB email body went straight into
the MCP response and the model context — the same exhaustion risk
the PR already mitigates for Drive.

Added config.MAX_GMAIL_BODY_BYTES = 256 KiB (covers normal mail + a
generous newsletter/HTML headroom) and _truncate_body which clips
oversized bodies and appends a marker noting the original size so
the agent knows the view is partial instead of silently working from
truncated content. Used UTF-8 byte length so multibyte characters
don't slip past the cap.

Tests cover the over-cap truncation path and the under-cap passthrough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@MrBenJ

MrBenJ commented May 19, 2026

Copy link
Copy Markdown
Owner Author

Iteration 2 fixes — 88 unit tests green, ruff + mypy clean, CI passing.

  1. [High] Temp file perms during write window — Fixed in 6f07f40. Swapped _atomic_write_json from open(tmp_path, "w") to os.open(tmp_path, O_CREAT|O_EXCL|O_WRONLY, 0o600) + os.fdopen, so the file is never world-readable even under umask 0o022. Regression test forces umask 0o022, patches os.chmod to a no-op, and asserts the mode captured just before os.replace is still 0o600.

  2. [Medium] Native Drive export buffered entire body before cap — Fixed in 033ba31. Added _download_chunked which wraps the export request in MediaIoBaseDownload with 1 MiB chunks and checks buf.tell() between next_chunk() calls. For a 500 MiB Sheet the abort fires after ~11 chunks instead of buffering the whole payload. Test feeds 1 MiB chunks indefinitely (next_chunk never returns done) and asserts the abort lands well below 50 iterations.

  3. [Medium] Gmail body unbounded — Fixed in ac16ecf. Added config.MAX_GMAIL_BODY_BYTES = 256 KiB and _truncate_body in gmail_get_message. Oversized bodies are clipped with a marker exposing the original size, so the agent knows the view is partial instead of silently working from truncated content. UTF-8 byte length so multibyte chars don't slip past the cap.

Ready for re-review.

@aria-teknal-studio aria-teknal-studio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No findings.

The current head addresses the prior review findings:

  • src/multi_google_mcp/accounts.py now creates token temp files with os.open(..., 0o600) from creation time, with a permissive-umask regression test.
  • src/multi_google_mcp/tools/drive.py now streams native Google exports through MediaIoBaseDownload and aborts once the accumulated buffer exceeds MAX_DRIVE_BYTES, with coverage for the oversized-export path.
  • src/multi_google_mcp/tools/gmail.py now truncates full message bodies before returning them through MCP, with tests for both oversized and under-cap bodies.

Verification:

  • uv run ruff check . passed.
  • uv run mypy passed.
  • uv run pytest passed: 88 tests.
  • GitHub PR check "pytest + ruff + mypy" is passing.

Residual risk:
The live Google e2e smoke test remains opt-in by design because it requires real credentials and network access. That is reasonable for CI, but it should be run manually before relying on this against production Google accounts.

@MrBenJ
MrBenJ merged commit 97d5613 into main May 19, 2026
1 check passed
@MrBenJ
MrBenJ deleted the feat/multi-google-mcp-server-implementation-p branch May 19, 2026 06:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants