Content
# google-mcp-rs
A multi-tenant **Model Context Protocol** server for **Google Workspace**, written in Rust. Built around streamable HTTP transport with full **OAuth 2.1** so it plugs straight into Claude.ai, Claude Code, ChatGPT custom connectors, Cursor, or any MCP client that speaks the 2025-11-25 authorization spec.
It also runs in **single-tenant stdio mode**, packaged as a one-click **Claude Desktop extension** ([`.mcpb`](#quick-start--claude-desktop-extension-mcpb)) — no TLS certificate, no tunnel, no inbound network exposure.
> **Status:** v0.8.0 — Gmail (25) + Sheets (11) + Drive (14) + Docs (12) + Calendar (14) live. **76 tools** total, plus a path-based **file exchange** (attach/upload/download by path, no base64) and 2 opt-in maintenance tools gated by `FILE_MAINTENANCE_TOOLS`. Claude Desktop bundles add a 77th tool, `google_authenticate`, for in-chat sign-in.
## Why
The first-party Google Workspace MCP server is missing fundamentals (you cannot send an email from it). Existing community servers are Python or single-tenant. `google-mcp-rs` aims to be the Rust server you actually want to deploy:
- **Full Gmail / Sheets / Drive / Docs / Calendar surface** — 76 tools covering email (search/threads/drafts/send/labels/organize), spreadsheets (CRUD on values + ranges + tabs + raw batchUpdate for formatting/charts), Drive (upload, download, export Google Docs to PDF/CSV/XLSX, share, copy, trash), Google Docs (read as plain text, append/insert/replace, raw batchUpdate for formatting and structure), and Google Calendar (calendars + events CRUD, free/busy, quick-add, attendee responses, recurrence).
- **Multi-tenant by design** — every user does their own Google OAuth dance. Refresh tokens are encrypted at rest with AES-256-GCM and bound to the user's Google `sub` via AAD.
- **OAuth 2.1 done right** — RFC 9728 protected resource metadata, RFC 8414 authorization server metadata, RFC 7591 dynamic client registration, RFC 8707 audience binding, PKCE-S256.
- **Two transports, one binary** — **streamable HTTP** (multi-tenant: one running instance serves many MCP clients and many Google accounts at once), or **stdio** (single-tenant: Claude Desktop launches it as a local child process, no TLS and nothing on the network). See [Quick start — Claude Desktop](#quick-start--claude-desktop-extension-mcpb).
- **One binary, distroless image** — small surface, no runtime dependencies.
## Architecture overview
`google-mcp-rs` plays two OAuth roles:
1. **Resource Server** — gates `/mcp` and returns `401 + WWW-Authenticate` with a `resource_metadata` URL pointing at `/.well-known/oauth-protected-resource/mcp`.
2. **Authorization Server** — serves `/.well-known/oauth-authorization-server`, `/oauth/register` (DCR), `/authorize`, `/oauth/token`. MCP clients self-register and obtain MCP JWTs from us.
Because Google's OAuth lacks dynamic client registration and won't accept arbitrary `aud` claims, the server runs in **proxy mode**:
```
MCP client (Claude.ai)
│
│ 1. /mcp ─── 401 + WWW-Authenticate ───▶
│ 2. /.well-known/oauth-protected-resource/mcp
│ 3. /oauth/register ◀── mcp_client_id, mcp_client_secret
│ 4. /authorize?response_type=code&code_challenge=...&redirect_uri=...
▼
google-mcp ──── redirect ───▶ accounts.google.com (consent screen)
│
◀────── /oauth/google/callback?code=… ◀─────┘
│ (server stores Google refresh token, encrypted)
│
│ 5. redirect to MCP client's redirect_uri with our code
│ 6. /oauth/token (exchange code + PKCE verifier) ─▶ MCP JWT
▼
MCP client → /mcp Authorization: Bearer <MCP JWT>
tools call Gmail with the user's stored refresh token
```
State is threaded MCP-client → Google → callback via single-use opaque tokens stored in SQLite (5-minute TTL). MCP JWTs are HS256 signed, bound to the user's Google `sub`, audience-scoped to `${BASE_URL}/mcp`.
## Deployment posture
This server is designed to be **self-hosted on your own machine** — typically one instance per workstation, listening on `127.0.0.1`, serving multiple MCP clients and multiple Google accounts at once. The quick-start below walks you through exactly that.
Public-internet deployment is *possible* — the OAuth flow, crypto (AES-256-GCM with AAD-bound `sub`, Argon2id-hashed client secrets, S256 PKCE, single-use codes with 5-minute TTL), and per-tenant isolation are sound — but going public adds attack surface this codebase does not yet cover:
- **No rate limiting** on `/oauth/register`, `/oauth/token`, `/authorize`, or `/mcp`. Brute-force and DoS are unmitigated.
- **No per-user JWT revocation.** Tokens have a 30-day lifetime; the only cut-off is rotating `JWT_SECRET`, which logs everyone out.
- **Audience binding skipped on `Host: localhost`** for local-dev convenience. Public instances must enforce that the token's `aud` claim matches the deployed `BASE_URL` regardless of inbound `Host`.
- **No network egress policy** on the container — a compromised process could reach anywhere on the internet.
If those gaps don't fit your threat model, fork it. The architecture is set up to make those additions straightforward, and PRs are welcome.
## Quick start — Claude Desktop extension (`.mcpb`)
The fastest way to run this. Claude Desktop launches the binary locally over stdio, so there is **no TLS certificate, no tunnel, and no inbound network exposure**. Works on macOS, Windows, and Linux. (During sign-in only, a short-lived listener on `127.0.0.1:8433` catches Google's redirect; it is closed as soon as the flow finishes or times out.)
1. **Download** the latest `google-workspace-mcp.mcpb` from [Releases](https://github.com/matpb/google-mcp-rs/releases).
2. **Create your own Google OAuth client** — follow [step 1 below](#1-create-a-google-oauth-client), and register exactly this redirect URI:
```
http://localhost:8433/oauth/google/callback
```
(Google rejects the sign-in with `redirect_uri_mismatch` if this is missing.)
3. **Double-click the `.mcpb`.** Claude Desktop opens an install dialog asking for your **Google Client ID** and **Client Secret** — paste them in and install.
4. In any chat, ask Claude to run the **`google_authenticate`** tool. A browser opens, you approve access, and that is it. You only do this once.
That is the entire configuration. `JWT_SECRET` and `STORAGE_ENCRYPTION_KEY` are generated automatically on first run and stored beside the database at mode `0600` — **the bundle ships no secrets**. Your encrypted Google refresh token lives only on your machine (`~/.google-mcp.db`).
<details>
<summary><b>Platform notes (unsigned binaries)</b></summary>
The release binaries are **not code-signed or notarized yet**, so the OS may object the first time:
- **macOS** — if Claude Desktop cannot launch the server, clear the download quarantine once:
```bash
xattr -dr com.apple.quarantine ~/Library/Application\ Support/Claude/Claude\ Extensions/
```
- **Windows** — SmartScreen may warn on first run: **More info → Run anyway**.
- **Port 8433** must be free while you sign in (it is only used for the OAuth callback). If something else owns it, sign-in fails with a clear message.
</details>
To build the bundle yourself, or to scope the tool surface down, see [`mcpb/README.md`](mcpb/README.md).
## Quick start — HTTP server (local development)
### 1. Create a Google OAuth client
In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials):
1. Pick or create a GCP project.
2. Enable the **Gmail API**, **Sheets API**, **Drive API**, **Docs API**, and **Calendar API**.
3. **OAuth consent screen** → External, app name `google-mcp` (or whatever you want users to see), user support email, developer email.
4. Add scopes:
- `openid`
- `email`
- `https://www.googleapis.com/auth/gmail.modify`
- `https://www.googleapis.com/auth/spreadsheets`
- `https://www.googleapis.com/auth/drive`
- `https://www.googleapis.com/auth/documents`
- `https://www.googleapis.com/auth/calendar`
5. Add yourself + any beta users to the **Test users** list (until the app is verified, only test users can authorize — see [Caveats](#caveats)).
6. **Credentials** → **Create credentials** → **OAuth 2.0 Client ID** → **Web application**.
7. Authorized redirect URI: `${BASE_URL}/oauth/google/callback` (e.g. `http://localhost:8433/oauth/google/callback` for dev).
8. Save the client ID and client secret.
### 2. Configure the server
```bash
cp .env.example .env
$EDITOR .env # fill in GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, BASE_URL
openssl rand -hex 64 # JWT_SECRET
openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' # STORAGE_ENCRYPTION_KEY
```
### 3. Run it
```bash
cargo run --release
# or
docker compose up --build
```
The server listens on `http://0.0.0.0:8433` by default. `/health` returns `ok`. `/mcp` requires a valid bearer token.
### 4. Connect Claude Code
Add the server once per Google account you want to use. Each entry triggers its own OAuth dance, so you pick the matching Google account in the browser tab when prompted.
```bash
claude mcp add --transport http --scope user google-personal http://localhost:8433/mcp
claude mcp add --transport http --scope user google-work http://localhost:8433/mcp
```
Then in any Claude Code session run `/mcp` and authenticate each entry. The JWTs land in Claude Code's credential vault; the server is multi-tenant and keys on Google `sub`, so one running instance handles both accounts. JWT lifetime is 30 days; access tokens refresh transparently every hour.
For **Claude.ai / ChatGPT custom connectors / Cursor**, add a custom connector pointing at `http://localhost:8433/mcp` — these clients have to support local-loopback URLs and the MCP 2025-11-25 OAuth flow, which not all of them do yet.
## Configuration
| Variable | Required | Default | Purpose |
|---|---|---|---|
| `GOOGLE_CLIENT_ID` | yes | — | OAuth client ID from GCP Console |
| `GOOGLE_CLIENT_SECRET` | yes | — | OAuth client secret from GCP Console |
| `BASE_URL` | yes | — | Public URL of this server, used to compute redirect URIs and OAuth metadata |
| `JWT_SECRET` | yes | — | HS256 secret for signing MCP JWTs (32+ bytes) |
| `STORAGE_ENCRYPTION_KEY` | yes | — | 32 bytes, base64url-encoded — encrypts refresh tokens at rest |
| `DATABASE_URL` | no | `./google-mcp.db` | SQLite file path |
| `MCP_HOST` | no | `0.0.0.0` | Bind address |
| `MCP_PORT` | no | `8433` | Listen port |
| `CORS_ALLOW_LOCALHOST` | no | `false` | Allow `http://localhost:*` in CORS (dev only) |
| `ENABLED_DOMAINS` | no | all five | Comma-separated subset of `gmail,sheets,drive,docs,calendar`. Filters both the MCP tool surface and the OAuth scopes requested from Google. Unset = all five. See [Scoping the surface](#scoping-the-surface) below. |
| `FILE_ROOT` | no | — (disabled) | Absolute path to the file-exchange directory, bind-mounted into the container at the same path. Enables attaching/uploading by `path` and saving downloads by `dest_path` instead of base64. Unset = base64-only. See [File handling](#file-handling-attachments-uploads-downloads) below. |
| `FILE_MAINTENANCE_TOOLS` | no | `off` | Whether the directory-maintenance tools are exposed: `off` (neither), `info` (read-only `files_info`), or `full` (`files_info` + the deleting `files_cleanup`). Off by default, so no deletion/listing tool exists unless you opt in. Only meaningful when `FILE_ROOT` is set. |
| `RUST_LOG` | no | `google_mcp=info,rmcp=warn,reqwest=warn` | Tracing filter — keep `reqwest` ≤ `warn` to avoid logging URLs with PII |
## Scoping the surface
By default, the server exposes all 76 tools across all five Workspace domains and asks Google for the matching scope set during consent. For deployments that only need part of the surface, set `ENABLED_DOMAINS` to a comma-separated subset:
```bash
ENABLED_DOMAINS=gmail # Gmail-only: 25 tools, gmail.modify scope
ENABLED_DOMAINS=gmail,calendar # email + calendaring: 39 tools
ENABLED_DOMAINS=docs,drive # document workflow: 26 tools
```
Two things shrink in lockstep:
1. **Tool surface.** Only the listed domains' `tool_router` impls compose into the MCP server. The rest of the tools simply do not exist on this instance — they don't show up in `tools/list`, they don't burn agent context tokens, they can't be called.
2. **OAuth scopes.** The consent screen asks Google for `openid`, `email`, plus only the scopes for the listed domains. A user authorizing a Gmail-only deployment never grants the server access to their Drive or Calendar.
Domain names are case-insensitive and whitespace-trimmed (`Gmail`, `GMAIL`, `gmail ` all parse the same). Unset, empty, or `ENABLED_DOMAINS=` reverts to the full surface (backwards-compatible default). An unknown domain name fails fast at startup with the valid list in the error message.
## File handling (attachments, uploads, downloads)
Moving binary files through an MCP tool call means base64 — which inflates size ~33% and, worse, drags the whole blob through the model's context in both directions. Models are unreliable at emitting/parsing multi-megabyte base64. Because this server is designed to run on the **same machine** as its MCP client, it offers a far better path: a **shared filesystem exchange**.
Set `FILE_ROOT` to a directory and bind-mount it into the container **at the same absolute path** (the provided `docker-compose.yml` does this automatically). Then file tools can name files by path — bytes never touch the model's context:
| Instead of base64… | Do this |
| --- | --- |
| Attach a local file to an email | `gmail_send attachments=[{ "path": "<FILE_ROOT>/report.pdf" }]` |
| Save an email attachment to disk | `gmail_download_attachment dest_path="<FILE_ROOT>/in.pdf"` |
| Upload a local file to Drive | `drive_create_file path="<FILE_ROOT>/report.pdf"` |
| Download a Drive file to disk | `drive_download_file dest_path="<FILE_ROOT>/out.pdf"` |
| Export a Google Doc to disk | `drive_export_file dest_path="<FILE_ROOT>/doc.pdf"` |
It also does **server-side transfers** so bytes move Google↔Google without a round-trip through the model at all:
| Task | Do this |
| --- | --- |
| Attach a Drive file to an email | `gmail_send attachments=[{ "drive_file_id": "1AbC…" }]` |
| Save an email attachment to Drive | `gmail_download_attachment to_drive_folder_id="root"` |
**Safety & semantics.** Every path is confined to `FILE_ROOT` — absolute paths must live under it, relative paths resolve against it, and `..`/symlink escapes are rejected. `mime_type` is inferred from the filename when omitted. When `FILE_ROOT` is set, oversized downloads (>8 MB) refuse to return inline base64 and point you at `dest_path`. Leaving `FILE_ROOT` unset disables path-based exchange entirely and every tool falls back to base64 — so remote deployments still work, they just pay the base64 tax.
**Permissions.** The container runs as uid 65532, so the exchange directory must be writable by that uid. For a fresh dedicated directory, `chmod 0777 ~/.google-mcp/exchange` on a single-user workstation is simplest (or pre-create it owned by 65532). Files the server writes will be owned by 65532 on the host. If you point `FILE_ROOT` at a directory you already own (e.g. your Downloads folder, so you can attach files without copying them in), grant the container write access without opening it to the world using an ACL — `setfacl -m u:65532:rwx ~/Downloads` — or run the container as your own uid via a `docker-compose.override.yml` (`user: "1000:1000"`, after chowning the data volume). **Caveat:** whatever directory you choose becomes the scope of `files_cleanup`, so pointing `FILE_ROOT` at a busy folder like Downloads means an unfiltered `files_cleanup dry_run=false` would delete everything in it (except `keep/`) — always filter and dry-run first.
**Discoverability.** When `FILE_ROOT` is set, the MCP server's `instructions` (returned on connect) include a FILE HANDLING section that states the live exchange path and these rules, so any calling agent learns the protocol without being told.
**Keeping it tidy.** The exchange directory accumulates over time. Two optional tools can manage it — **off by default** and gated by `FILE_MAINTENANCE_TOOLS` (`off` / `info` / `full`), so a deletion tool never exists unless you opt in. Nothing is ever deleted automatically.
| Tool | Requires | Purpose |
| --- | --- | --- |
| `files_info` | `FILE_MAINTENANCE_TOOLS=info` or `full` | Report the exchange path, file count, total bytes, and a listing (oldest first). Read-only. |
| `files_cleanup` | `FILE_MAINTENANCE_TOOLS=full` | Delete files by age (`older_than_hours`) and/or name (`name_contains`). **Defaults to `dry_run: true`** — reports what would go and removes nothing until you pass `dry_run: false`. |
Anything under a `keep/` subdirectory of `FILE_ROOT` is invisible to `files_cleanup` and never deleted. If you point `FILE_ROOT` at a directory you manage yourself (e.g. your Downloads folder), leave `FILE_MAINTENANCE_TOOLS=off` (the default) so the server can never delete anything there.
## Tools
### Gmail (25)
| Tool | Purpose |
|---|---|
| `gmail_search_threads` | Search threads with Gmail query syntax |
| `gmail_get_thread` | Get a thread with all messages and full payload |
| `gmail_get_message` | Get a single message by ID |
| `gmail_list_messages` | List messages with optional query |
| `gmail_list_attachments` | List attachments on a message |
| `gmail_download_attachment` | Download an attachment by ID (returns base64) |
| `gmail_get_thread_url` | Build a Gmail web URL for a thread |
| `gmail_create_draft` | Create a draft (optionally as a reply) |
| `gmail_get_draft` | Get a draft by ID |
| `gmail_list_drafts` | List drafts |
| `gmail_update_draft` | Update an existing draft |
| `gmail_delete_draft` | Delete a draft |
| `gmail_send_draft` | Send a previously created draft |
| `gmail_send` | Send an email (with `reply_to_message_id`, `cc`, `bcc`, attachments) |
| `gmail_list_labels` | List all labels |
| `gmail_get_label` | Get a label by ID with message counts |
| `gmail_create_label` | Create a label (with optional color) |
| `gmail_update_label` | Rename or restyle a label |
| `gmail_delete_label` | Delete a label |
| `gmail_modify_labels` | Add/remove labels on a message OR thread |
| `gmail_mark_read` | Mark messages as read |
| `gmail_mark_unread` | Mark messages as unread |
| `gmail_archive` | Archive messages |
| `gmail_trash` | Move messages to trash |
| `gmail_get_profile` | Return the connected account email and granted scopes |
### Sheets (11)
| Tool | Purpose |
|---|---|
| `sheets_create` | Create a new spreadsheet (optionally with named tabs + locale + time zone) |
| `sheets_get` | Get a spreadsheet's metadata (or specific A1 ranges with cell data) |
| `sheets_get_values` | Read values from an A1 range |
| `sheets_batch_get_values` | Read values from multiple A1 ranges in one call |
| `sheets_update_values` | Write a 2-D array of values into a range (`RAW` or `USER_ENTERED`) |
| `sheets_append_values` | Append rows to a table-shaped range |
| `sheets_clear_values` | Clear values in a range (formatting preserved) |
| `sheets_batch_update_values` | Write to multiple ranges in one API call |
| `sheets_batch_update` | Schema-level batch update — add/delete sheets, formatting, conditional formatting, charts, banding (raw `requests[]` passthrough) |
| `sheets_add_sheet` | Convenience: add a new tab |
| `sheets_delete_sheet` | Convenience: remove a tab by `sheetId` |
### Docs (12)
**Read & basic write:**
| Tool | Purpose |
|---|---|
| `docs_create` | Create an empty Google Doc with a title |
| `docs_get` | Get a document's full structured payload (paragraphs, runs, tables, lists, headers, footers, styles) |
| `docs_get_text` | Fetch the doc and return its body as **flattened plain text** — the high-value tool for agents reading content |
| `docs_append_text` | Append plain text to the end of the document |
| `docs_insert_text` | Insert plain text at a specific character index |
| `docs_replace_text` | Find every occurrence of a string and replace it (case-sensitive optional) |
**Formatting helpers** (hide the index/schema math):
| Tool | Purpose |
|---|---|
| `docs_insert_styled` | Insert text with **text styling** (bold, italic, underline, strikethrough, font size, font family, foreground/background `#rrggbb` color, link, baseline) and/or **paragraph styling** (HEADING_1…6, TITLE, SUBTITLE, NORMAL_TEXT). Append by default; pass `at_index` for a specific position. Returns the inserted range so chained ops can keep going |
| `docs_format_text` | Apply text and/or paragraph styling. Pass EITHER `range: {start_index, end_index}` for an exact slice OR `match: "..."` to style every occurrence (case-insensitive by default). Returns the list of ranges affected |
| `docs_make_list` | Convert paragraphs in a range to a bulleted or numbered list. `style: "bullet"` (default) or `"numbered"`, or pass `bullet_preset` directly (e.g. `BULLET_CHECKBOX`, `NUMBERED_DECIMAL_NESTED`) |
| `docs_insert_table` | Insert an empty table (`rows` × `columns`) at a position. Capped at 100 rows × 20 columns per Google's API limits |
| `docs_insert_image` | Insert an inline image from a public HTTPS URL with optional explicit `width_pt` / `height_pt`. PNG / JPEG / GIF only |
**Escape hatch:**
| Tool | Purpose |
|---|---|
| `docs_batch_update` | Raw `requests[]` passthrough for everything else (named ranges, section breaks, custom paragraph spacing, conditional formatting, etc.) |
> **Indexes are UTF-16 code units** (matches what `docs_get` returns). For ASCII text this equals byte length; for emoji or non-BMP characters one Unicode scalar can occupy two units.
### Drive (14)
| Tool | Purpose |
|---|---|
| `drive_list_files` | Search/list files with Drive query syntax |
| `drive_get_file` | Fetch file metadata |
| `drive_create_folder` | Create a folder (optionally nested) |
| `drive_create_file` | Upload a file (multipart, ≤ ~5 MB content) |
| `drive_update_metadata` | Rename, re-describe, move (add/remove parents), star |
| `drive_update_content` | Replace a file's binary content |
| `drive_download_file` | Download bytes (returns base64) |
| `drive_export_file` | Export a Google Doc/Sheet/Slide to PDF/CSV/XLSX/markdown/etc. |
| `drive_copy_file` | Duplicate a file |
| `drive_trash_file` | Move to Trash (reversible) |
| `drive_delete_permanent` | **Irreversible** delete — prefer `drive_trash_file` |
| `drive_share_file` | Add a permission (user/group/domain/anyone × reader/commenter/writer/…) |
| `drive_list_permissions` | List sharing entries |
| `drive_delete_permission` | Remove a sharing entry by ID |
### Calendar (14)
| Tool | Purpose |
|---|---|
| `calendar_list_calendars` | List the calendars on the user's calendar list (primary + subscribed). Use to discover IDs |
| `calendar_get_calendar` | Get a calendar's metadata (`"primary"` for the user's main calendar) |
| `calendar_create_calendar` | Create a secondary calendar |
| `calendar_delete_calendar` | **Irreversibly** delete a secondary calendar (primary cannot be deleted) |
| `calendar_list_events` | List/search events. Expands recurrences by default (`single_events=true`). Filter by `time_min`/`time_max`/`q`/`updated_min` |
| `calendar_get_event` | Get a single event by ID |
| `calendar_create_event` | Create an event with structured fields — timed (`start_date_time` + `end_date_time` RFC3339) or all-day (`start_date` + `end_date`), attendees, RRULE recurrence, popup reminders, optional Google Meet link, color, visibility, transparency, plus `extra_event_fields` escape hatch |
| `calendar_quick_add_event` | Natural-language event creation (`Lunch with Sara tomorrow at 1pm`) |
| `calendar_patch_event` | Partial update — same structured fields as create, all optional. **Setting `attendees` REPLACES the list** |
| `calendar_delete_event` | Delete an event (use `send_updates="all"` to notify guests) |
| `calendar_move_event` | Move an event to a different calendar |
| `calendar_respond_to_event` | Set the user's (or another attendee's) `responseStatus`: accepted / declined / tentative |
| `calendar_freebusy` | Free/busy across one or more calendars within a time window — for conflict checking before scheduling |
| `calendar_list_colors` | Calendar + event color palette (for `colorId`) |
> **Default `send_updates=none`.** Calendar mutations don't email guests by default — pass `send_updates="all"` (or `"externalOnly"`) explicitly when you want a notification. This keeps agents from spamming inboxes during retries or batch operations.
## Error contract
Every error returned by the server includes a structured `data` payload alongside the human-readable `message`. Agents can switch on `category` and `retryable` programmatically without parsing the message string.
```json
{
"code": -32002,
"message": "gmail message not found: 18a3b…",
"data": {
"category": "not_found",
"retryable": false,
"service": "gmail",
"http_status": 404,
"upstream_reason": "notFound",
"resource_kind": "message",
"resource_id": "18a3b…",
"hint": "Use gmail_search_threads or gmail_list_messages to discover valid message IDs."
}
}
```
| `category` | `retryable` | When | What the agent should do |
|---|---|---|---|
| `invalid_input` | no | Tool args malformed (missing field, wrong type, mutually exclusive options, no recipients, etc.) | Read `hint`, fix args, retry |
| `not_found` | no | Resource ID does not exist or is not visible to this account | Read `resource_kind` + `hint`, call the right discovery tool, retry with a new ID |
| `auth_required` | no | User must re-authorize (refresh token revoked, account not registered with this server) | Surface `reconnect_url` to the user; this is unrecoverable from the agent's side |
| `auth_invalid` | no | JWT itself is bad (expired, wrong signature, audience mismatch) | Re-run the OAuth flow at `/authorize` |
| `rate_limited` | **yes** | Google rate limit hit | Back off (exponential: 250ms → 1s → 4s) and retry |
| `permission_denied` | no | Account lacks permission for this resource | Don't retry; surface to the user |
| `transient` | **yes** | Network blip / Google 5xx | Retry once or twice with a 1–5s delay |
| `upstream` | no | Uncategorized upstream response | Inspect `http_status` + `message` |
| `internal` | no | Server-side bug | Retry won't help |
**Agent recovery patterns:**
- Loop with `gmail_send` and a malformed recipient → `invalid_input` → fix the email format, retry.
- `gmail_get_message` with stale ID → `not_found` with `resource_kind: "message"` → call `gmail_search_threads` to refresh, retry.
- Any tool returns `rate_limited` → sleep and retry; backoff is the agent's responsibility.
- Any tool returns `auth_required` with `reconnect_url` → ask the user to reconnect the MCP server; do not retry the same tool.
## Caveats
- **Unverified app cap.** Until your OAuth client is verified by Google, only **test users** (added in the GCP Console) can authorize, and the app is hard-capped at 100 users for its lifetime. `gmail.modify`, `drive`, `spreadsheets`, `documents`, and `calendar` are all **restricted/sensitive scopes** — verification for the full set requires a [CASA assessment](https://cloud.google.com/security/compliance/casa) (2–6 weeks, plus privacy policy URL, terms of service URL, demo video).
- **One Google account per JWT (Phase 1).** To use a second Google account, complete the OAuth flow again. Per-tool `account` parameter for in-session switching is on the Phase 2 roadmap.
- **No send-safety knob.** Tools execute `gmail_send` immediately. If you want a draft-only mode, do not expose `gmail_send` to the agent — point it at `gmail_create_draft` instead.
- **ID token signature not verified in MVP.** The server trusts Google's ID token because the channel to Google's token endpoint is TLS. Hardening to verify against Google's JWKS is on the roadmap.
- **Refresh token revocation.** If the user revokes the app's access in their Google Account, the next tool call returns a `ReconnectRequired` error pointing at `/authorize`.
- **PII in logs.** Tracing intentionally redacts subject, body, recipients, and search queries. Logs only structural metadata (counts, lengths, durations, opaque `sub` IDs). Pin `RUST_LOG` to keep `reqwest` ≤ `warn` so request URLs (which can carry PII in query params) are not logged.
## Roadmap
- **Per-tool `account` parameter** for multi-account workflows in a single MCP session.
- **Forms, People (Contacts), Tasks** — the rest of the Workspace surface.
- **Resumable Drive uploads** for files larger than ~5 MB.
- **Hardening:** ID token JWKS verification, refresh token rotation, structured per-account audit log.
## Contributing
Pull requests welcome. CI runs `cargo fmt --check`, `cargo clippy -- -D warnings`, and `cargo test`. Please keep secrets out of fixtures and tests.
## License
MIT — see [LICENSE](LICENSE).
MCP Config
Below is the configuration for this MCP Server. You can copy it directly to Cursor or other MCP clients.
mcp.json
Connection Info
You Might Also Like
No recommendations available