Multi-Google MCP Server Implementation Plan - #1
Conversation
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
left a comment
There was a problem hiding this comment.
Findings:
-
[High] Account labels are used as filesystem paths without validation.
Insrc/multi_google_mcp/accounts.py:33,_path()buildsACCOUNTS_DIR / f"{label}.json"directly from a caller-controlled label. That label comes from both the auth CLI and every MCP tool'saccountargument. Labels such as../client_secretor../../some/pathcan escapeaccounts/;save()can overwrite JSON files outside the account store,remove()can unlink them, andcredentials()/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 insideACCOUNTS_DIR. Add tests for traversal attempts onsave,remove, andcredentials. -
[Medium] Drive reads can return unbounded file contents through stdio.
src/multi_google_mcp/tools/drive.py:44reads the entire Drive object/export into memory, andsrc/multi_google_mcp/tools/drive.py:59base64-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 arbitrarycontentinsrc/multi_google_mcp/tools/drive.py:85andsrc/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. -
[Medium] Token refresh writes are not atomic or protected against concurrent writers.
src/multi_google_mcp/accounts.py:132reads the token JSON, mutates it, and rewrites it withpath.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. -
[Medium] Most tool failures escape the MCP error handling path.
src/multi_google_mcp/server.py:316only catchesMultiGoogleMcpError. Google APIHttpError, invalid base64 in Drive upload/update, malformed account JSON, missing keys, and unexpected extra arguments all bubble out ofcall_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. -
[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 ., anduv 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.
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>
|
Addressed all 5 findings (commits 2bf026e..8194698, 85 unit tests all green).
Ready for re-review. |
aria-teknal-studio
left a comment
There was a problem hiding this comment.
Findings:
-
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. -
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. -
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.
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>
|
Iteration 2 fixes — 88 unit tests green, ruff + mypy clean, CI passing.
Ready for re-review. |
aria-teknal-studio
left a comment
There was a problem hiding this comment.
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.
Summary
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 explicitaccountlabel.multi-google-mcp-auth add|list|remove) that runs Google'sInstalledAppFlowoutside the server, persisting per-label tokens at~/.config/multi-google-mcp/accounts/<label>.json(chmod 600) with refresh-write-back semantics.TOOL_REGISTRYso list/call stay in sync; pure shaping helpers keep tool layers thin and unit-testable.scripts/e2e_smoke.pyboots 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 .— cleanuv run mypy— clean under `strict = true`uv run multi-google-mcp-auth --help— argparse help renders, all three subcommands listedMCP_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