Google Workspace MCP
Provides read/write access to Gmail, enabling tools to manage messages and mailbox data through the Gmail API.
Provides read/write access to Google Chat, enabling interaction with chat spaces and messages via the Chat API.
Provides read/write access to Google Docs, enabling document content operations through the Docs API.
Provides access to Google Meet, enabling management of meeting resources via the Meet REST API.
Provides access to Google Photos, enabling photo library and picker operations via the Photos Library and Photos Picker APIs.
Provides read/write access to Google Sheets, with tools for reading ranges, writing values, clearing ranges, and working with formulas via the Sheets API.
Provides read/write access to Google Slides, enabling presentation content operations through the Slides API.
Provides read/write access to Google Tasks, enabling task list and task management via the Tasks API.
Provides access to YouTube, enabling management of YouTube resources via the YouTube Data API v3.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Google Workspace MCPcreate a new Google Sheet called 'Budget 2025' and share it with me"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Google Workspace MCP — Cloudflare Workers
A remote MCP server that gives Claude (or any MCP client) full read/write access to your own Google Workspace — Sheets first, plus Drive, Docs, Gmail, Calendar, Tasks, Contacts, Chat, Slides, Forms, Photos, YouTube and Meet — running on Cloudflare Workers (free tier) with real Google OAuth user consent. No service account: when you connect you get Google's standard "choose an account / allow" screen and the server acts as you.
Two deployments from the same code (the same two-mode layout as the author's zoho-analytics-mcp-worker):
claude.ai connector — primary | bearer worker | |
Entry / config |
|
|
Worker name |
|
|
For | Claude web / desktop / mobile custom connectors, Claude Code, any OAuth-capable MCP client | Claude Code, scripts, clients that send a bearer header |
Auth in front |
|
|
Google identity | every user signs in with their own account; the refresh token lives in that grant's encrypted props | one account (yours), connected once via |
Deploy |
|
|
Both expose the identical 168-tool surface via McpAgent (Cloudflare agents) + @modelcontextprotocol/sdk: Streamable HTTP at /mcp, SSE at /sse, compact JSON responses.
Based on the author's earlier Workers: the two-mode layout and the upstream-OAuth grant handling come from
zoho-analytics-mcp-worker(its bearer + multi-user OAuth workers), the one-time/google/authowner login and single-use link frompodio-mcp-worker, tool/registry conventions, CI andSECURITY.md/CLAUDE.mdlayout frommake-mcp-workerandsolaredge-mcp-worker, and the read-file/base64 lessons fromgithub-mcp-proxy.
Contents
Related MCP server: Google-MCP-Server
Quick start
git clone https://github.com/adamcfield/google-workspace-mcp-worker && cd google-workspace-mcp-worker
npm ci
./scripts/setup.sh --no-secrets # wrangler login → creates OAUTH_KV + TOKEN_KV → deploys both workers → prints URLs + redirect URIs
# → Google Cloud: enable the APIs, create the OAuth client with BOTH /callback redirect URIs (docs/GCP-SETUP.md)
npx wrangler secret put GOOGLE_CLIENT_ID -c wrangler.oauth.jsonc # claude.ai connector worker
npx wrangler secret put GOOGLE_CLIENT_SECRET -c wrangler.oauth.jsonc
node scripts/smoke.mjs https://google-workspace-mcp-oauth.<sub>.workers.dev
# → Claude → Settings → Connectors → Add custom connector → https://google-workspace-mcp-oauth.<sub>.workers.dev/mcpOnly want the connector? ./scripts/setup.sh --oauth-only. Only the bearer worker? --bearer-only.
Google Cloud setup
Full click-by-click (and gcloud) guide: docs/GCP-SETUP.md. In short:
Project — create or reuse one (
gcloud projects create …). No billing needed.Enable 14 APIs — Sheets, Drive, Docs, Gmail, Calendar, Tasks, People, Chat, Slides, Forms, Photos Library, Photos Picker, YouTube Data v3, Meet REST:
gcloud services enable sheets.googleapis.com drive.googleapis.com docs.googleapis.com gmail.googleapis.com \ calendar-json.googleapis.com tasks.googleapis.com people.googleapis.com chat.googleapis.com slides.googleapis.com \ forms.googleapis.com photoslibrary.googleapis.com photospicker.googleapis.com youtube.googleapis.com meet.googleapis.comChat additionally needs a Chat app configuration (name + avatar) under Google Chat API → Configuration, or every Chat call is a 403.
OAuth consent screen — Internal if your account is a Google Workspace account in that org (no verification, no 7-day token expiry, no warning screen). Otherwise External and Publish app → In production — Testing status expires refresh tokens every 7 days. You will see Google's "unverified app" interstitial once per sign-in: Advanced → Go to … (unsafe); expected for a self-owned client.
OAuth client — Web application, authorized redirect URIs (one per deployed worker, exact, https, no trailing slash):
https://google-workspace-mcp-oauth.<your-subdomain>.workers.dev/callbackhttps://google-workspace-mcp.<your-subdomain>.workers.dev/callback
Copy the client id + secret. One client serves both workers.
Deploy
Requirements: Node 22+, a Cloudflare account. Everything runs on the free plan (Workers + KV + Durable Objects with SQLite storage).
npm ci
npx wrangler login
# claude.ai connector worker (primary)
npx wrangler kv namespace create OAUTH_KV # paste the id into wrangler.oauth.jsonc
npx wrangler deploy -c wrangler.oauth.jsonc # → https://google-workspace-mcp-oauth.<sub>.workers.dev
npx wrangler secret put GOOGLE_CLIENT_ID -c wrangler.oauth.jsonc
npx wrangler secret put GOOGLE_CLIENT_SECRET -c wrangler.oauth.jsonc
# bearer worker (optional)
npx wrangler kv namespace create TOKEN_KV # paste the id into wrangler.jsonc
npx wrangler deploy # → https://google-workspace-mcp.<sub>.workers.dev
npx wrangler secret put MCP_AUTH_TOKEN # e.g. openssl rand -hex 32
npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put GOOGLE_CLIENT_SECRETscripts/setup.sh runs exactly these steps interactively (both workers, or --oauth-only / --bearer-only). Secrets apply immediately (no redeploy).
Public pages: / (landing page — doubles as the application home page for Google's consent-screen branding) and /privacy (plain-text privacy policy — use https://<oauth-worker>/privacy as the privacy policy URL when the consent screen is External).
CI/CD: .github/workflows/ci.yml typechecks, tests, checks the README tables and dry-run-bundles both workers on every push; .github/workflows/deploy.yml deploys both on push to main when the repo has CLOUDFLARE_API_TOKEN (template Edit Cloudflare Workers + Workers KV Storage: Edit) and CLOUDFLARE_ACCOUNT_ID secrets — or connect the repo to Cloudflare Workers Builds. Worker secrets persist across deploys.
Connect Claude (claude.ai connector)
Works in Claude web, desktop and mobile (Pro/Max/Team/Enterprise; org admins may need to allow custom connectors) — the connector settings sync across your devices.
Claude → Settings → Connectors → Add custom connector.
Name:
Google Workspace· Remote MCP server URL:https://google-workspace-mcp-oauth.<sub>.workers.dev/mcp. Leave OAuth client ID / secret empty (the server supports dynamic registration — Claude registers itself).Add → Connect. A window opens on the Worker's consent page (it names Claude as the requesting client and lists the Google permissions) → Continue with Google → Google account chooser → (External app: "Google hasn't verified this app" → Advanced → Go to Google Workspace MCP) → allow all permissions → Continue.
Back in Claude the connector shows Connected; enable it in a chat's tools menu. Ask: "Run google_whoami" to confirm the account and granted scopes.
Under the hood claude.ai fetches /.well-known/oauth-protected-resource/mcp (from the WWW-Authenticate hint on the 401), then /.well-known/oauth-authorization-server, registers a client at /register, runs PKCE through /authorize → /token, and calls /mcp with the bearer it received. All of that is served by workers-oauth-provider; node scripts/smoke.mjs <url> checks every piece.
Claude Code: claude mcp add --transport http google-workspace https://google-workspace-mcp-oauth.<sub>.workers.dev/mcp runs the same OAuth flow.
Bearer worker
For clients that send a static header instead of doing OAuth.
Deploy (above) and set
MCP_AUTH_TOKEN,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET.Connect your Google account once — open
https://google-workspace-mcp.<sub>.workers.dev/google/auth?key=<MCP_AUTH_TOKEN>in a browser (or keep the secret out of browser history:curl -X POST -H "Authorization: Bearer <MCP_AUTH_TOKEN>" https://…/google/auth/link→ open the single-useurlit returns). Google's consent screen → done. The refresh token is stored AES-GCM-encrypted inTOKEN_KV(key derived fromMCP_AUTH_TOKEN+GOOGLE_CLIENT_SECRET). Alternative: set aGOOGLE_REFRESH_TOKENsecret and skip the browser.Check:
curl -H "Authorization: Bearer <MCP_AUTH_TOKEN>" https://…/google/status. Disconnect:curl -X DELETE …/google/auth(revokes at Google).Use it:
claude mcp add --transport http google-workspace https://…/mcp --header "Authorization: Bearer <MCP_AUTH_TOKEN>", or any MCP client with a bearer header.ALLOWED_EMAILSlimits which account may complete step 2.
Verify end-to-end
After connecting, in Claude:
"Use sheets_read_range on spreadsheet
<id>rangeSheet1!A1:D10with formulas" — you getvalues+formulas."Write 'hello' into
Sheet1!Z1000then clear it" —sheets_write_rangethensheets_clear_range."List my next 3 calendar events" —
calendar_list_eventswithmax_results: 3."Create a Gmail draft to me with subject 'MCP test', then delete that draft" —
gmail_create_draft→gmail_delete_draft.
Can't reach *.workers.dev from where you are (locked-down network)? The Smoke GitHub Actions workflow (Actions → Smoke → Run workflow) runs the same script from a GitHub runner; give it the worker URL(s), and with an MCP_AUTH_TOKEN repo secret plus a spreadsheet id it runs the bearer --e2e checks too.
The same four checks run without Claude against either worker: E2E_SPREADSHEET_ID=<id> node scripts/smoke.mjs <origin> --e2e — it reads a range, writes one cell and reverts it, lists the next 3 events, and creates + deletes a draft. For the connector worker get a token first with node scripts/login.mjs <origin> (runs the OAuth flow in your browser, saves .mcp-token.local); for the bearer worker pass MCP_TOKEN=<MCP_AUTH_TOKEN>.
Scopes
Requested at sign-in (single source of truth: src/google/scopes.ts); you can untick any on Google's screen and the matching tools will return a clear "missing scope" error.
Narrower access. The table lists every scope, but a deployment only asks for the scopes of the groups it enables, plus openid and userinfo.email. A deployment used for spreadsheet work can set ENABLED_TOOL_GROUPS=sheets, and Google then asks only for spreadsheets (you open files by URL or id, because finding one by name needs Drive). The sheets_power_user profile (sheets + drive) adds full drive and still asks for no Gmail. Someone who connected before the change must revoke the app at https://myaccount.google.com/permissions and reconnect, because Google carries earlier grants forward. See docs/OPERATIONS.md.
Group | Scope URL | Class | Why |
Identity |
| non-sensitive | Identify the signed-in Google account |
Identity |
| non-sensitive | Read the account email (grant key + allow-list) |
Sheets |
| sensitive | Read/write all spreadsheets |
Drive |
| restricted | Full Drive access: search, read, upload, share, folders |
Docs |
| sensitive | Read/write all Google Docs |
Gmail |
| restricted | Read, search, draft, send, label (no permanent delete) |
Calendar |
| sensitive | Read/write calendars and events |
Tasks |
| sensitive | Read/write task lists and tasks |
Contacts (People) |
| sensitive | Read/write personal contacts |
Chat |
| sensitive | List/get Chat spaces |
Chat |
| sensitive | Read, send, edit, delete Chat messages as you |
Slides |
| sensitive | Read/write presentations |
Forms |
| sensitive | Create/edit forms |
Forms |
| sensitive | Read form responses |
Photos |
| sensitive | Upload photos/videos and create albums |
Photos |
| sensitive | List/search albums and media this app created |
Photos |
| sensitive | Edit albums/media this app created |
Photos |
| sensitive | Read media you pick in a Photos Picker session (any library photo) |
YouTube |
| sensitive | Read your channels, playlists, videos, stats |
Meet |
| sensitive | Create Meet spaces; read records of meetings you created |
Meet |
| sensitive | Read Meet spaces, conference records, participants, transcripts |
APIs to enable (14): sheets.googleapis.com, drive.googleapis.com, docs.googleapis.com, gmail.googleapis.com, calendar-json.googleapis.com, tasks.googleapis.com, people.googleapis.com, chat.googleapis.com, slides.googleapis.com, forms.googleapis.com, photoslibrary.googleapis.com, photospicker.googleapis.com, youtube.googleapis.com, meet.googleapis.com.
Why these differ slightly from the original spec:
Photos: Google removed
photoslibrary,photoslibrary.readonlyandphotoslibrary.sharingon 2025-03-31 (requesting them fails the whole consent). The Library API now only returns media/albums created by this app;photos_upload_media_item/photos_search_media_itemswork on that. For any photo in your library use the Picker flow:photos_create_picker_session→ open the URL, select →photos_list_picked_media_items.Meet: the REST API v2 scopes are
meetings.space.createdandmeetings.space.readonly(covering spaces, conference records, participants, recordings, transcripts).meet.conference.media.readonlydoes not exist.openid+userinfo.emailidentify the account so grants are keyed by email andALLOWED_EMAILScan be enforced.
Tools
Sheets safety features (added in 1.2.0):
Write verification —
sheets_write_range,sheets_batch_write_ranges,sheets_append_rowsandsheets_batch_update_spreadsheetre-read what they wrote (verify, default on) and returnverification: {cells, errors: [{cell, type, message}], ok}plus awarningline when a formula evaluates to#REF!,#DIV/0!,#N/A, … A broken formula is never a silent success.sheets_batch_update_spreadsheet— requests apply in order and all-or-nothing, and{writeValues: {range, values}}(or{sheetId, range: "A1:C2", values}) insiderequestswrites values in the same call as structural changes (insert/delete rows, …).dry_run: truereturns a plan without writing (per request: type, sheet, A1 range, cell count, plain-language effect, warning, and the current contents of ranges that would be overwritten); fordeleteDimension/deleteRange/deleteSheetit also shows the contents about to be lost and the formulas elsewhere that read them (becomes: "#REF!"). A real run answers withreply: "summary"by default —applied,totals(rowsInserted,cellsFormatted,cellsWritten,sheetsAdded, …), warnings grouped by text and only the notable changes — or every request's effect withreply: "full". After structural changespost_check(default on) re-reads the tabs and reports error cells aspostCheck;snapshot: truefirst copies each tab a destructive request touches to a hidden backup tab (delete it when no longer needed). These grid reads, and the re-readverifymakes after a batch, cover at most 100,000 grid cells and 8 MB of response each and ask only for the cell fields they use; tabs and written ranges left out are named. A dry run reads the current contents of a range only when its whole block is at most 200 grid cells, within 8 MB.sheets_delete_sheet/sheets_clear_range— the same opt-insnapshot(a hidden backup of the tab first).sheets_delete_sheetalso checks (post_check, default on) the tabs whose formulas read the deleted tab and reports their error cells aspostCheck.sheets_read_cellsfields— pick facets (value,formula,note,link,validation,number_format,text,fill,align,borders,format); colors are hex, identical borders collapse to{all}, and the API field mask matches the selection so both the request and the output stay small.Model checks —
sheets_audit_spreadsheet(error cells with Google's message, circular references, references to missing sheets, formulas that break their column/row pattern, ranges that stop before the data does),sheets_trace_precedents(where a number comes from) andsheets_trace_dependents(what breaks if a cell changes).
Total: 168 tools in 14 groups. R = read-only, W = writes, D = destructive/irreversible.
Tool | Mode | Scope | What it does |
| R |
| Identity check for the connected Google account: email, hosted domain (hd, Workspace accounts only), the OAuth scopes actually granted (shor |
| D | — | Escape hatch: call ANY Google REST endpoint (https://*.googleapis.com/...) with the signed-in user's token |
| R | — | What this deployment enables: its tool groups (name, prefix such as 'gmail_', one-line hint, counts), the groups switched off here (disabled |
Tool | Mode | Scope | What it does |
| R |
| List Google Sheets spreadsheets in Drive (optionally filtered by name/full-text query or folder) |
| R |
| Spreadsheet metadata: title, locale/timezone, every tab (sheetId, title, index, grid rowCount/columnCount, frozen rows/cols, hidden), named |
| R |
| Read cell values from a range in A1 notation (e.g |
| R |
| Read several ranges in one call (values.batchGet) |
| R |
| Full-fidelity cell read for a range: values, formulas, notes, hyperlinks, data validation and formatting — pick exactly which with |
| W |
| Overwrite a range with values (values.update) |
| W |
| Write several ranges in one call (values.batchUpdate) |
| W |
| Fill one formula or value across a range, relative references adjusting as with the fill handle |
| W |
| Append rows after the last row of the table that starts at |
| D |
| Clear values in a range (formatting is kept). |
| W |
| Run spreadsheets.batchUpdate requests — the full Sheets API: repeatCell/updateCells (formats, number formats, colors), updateBorders, insert |
| W |
| Add a new tab (sheet) to a spreadsheet. |
| D |
| Delete a tab by sheetId (irreversible — the tab and its data are gone). |
| W |
| Create a new spreadsheet (optionally with named tabs, initial data in the first tab, and inside a Drive folder) |
| W |
| Find & replace text across a tab or the whole spreadsheet (supports regex, match case, entire cell, inside formulas). |
| W |
| Copy a tab into another spreadsheet (sheets.copyTo). |
| R |
| Health check of a spreadsheet (or selected ranges): formula errors, plus warnings about broken fill-downs and short ranges |
| R |
| Dependency tree of a cell: its formula, the cells/ranges it reads (resolving sheet-qualified and named ranges), their values, and recursivel |
| R |
| Reverse dependency lookup: every formula in the spreadsheet that reads a given cell (directly, through a range that contains it, via a sheet |
Tool | Mode | Scope | What it does |
| R |
| Search Drive files (files.list across My Drive + shared drives) |
| R |
| File/folder metadata by id: name, mimeType, size (omitted for native Google files — Drive reports a placeholder there), timestamps, parents, |
| R |
| Read a file's content |
| W |
| Create a file in Drive from inline content (multipart upload) |
| W |
| Replace the content of an existing (non-Google-native) file with new bytes (media upload) |
| W |
| Create a new Drive folder / directory (optionally inside parent_id; default My Drive root) |
| W |
| Update file metadata: rename, set description, star/unstar, trash/untrash |
| W |
| Move a file/folder into another folder (Drive files have exactly one parent, so the current parent is replaced; to make a file appear in a s |
| W |
| Copy a file (not folders — Drive cannot copy folders) |
| D |
| Move a file/folder to trash (default, recoverable for 30 days) or delete it permanently (permanent=true — irreversible, also deletes a folde |
| R |
| List who has access to a file/folder: permission id, type (user/group/domain/anyone), role, emailAddress/domain, displayName, expirationTime |
| W |
| Share a file/folder: grant a role to a user/group (email), a whole domain, or anyone with the link |
| D |
| Revoke access by deleting a permission (id from drive_list_permissions) |
| R |
| List shared drives (Team Drives) the account can access: id, name, createdTime |
| R |
| Signed-in Drive user (email, display name) and storage quota (limit, usage, usageInDrive, usageInDriveTrash — bytes; limit absent = unlimite |
Tool | Mode | Scope | What it does |
| R |
| Read a Google Doc |
| R |
| Document skeleton without the prose: heading outline [{heading, level, startIndex, endIndex}], endIndex (append point is endIndex-1), tables |
| W |
| Create a new Google Doc (optionally with initial body text and inside a Drive folder — moving needs the drive scope) |
| W |
| Append text at the end of the document body (a newline is added first when the document already has content, so the text starts a new paragr |
| W |
| Insert text at a body index (1 = start of the document; use outline/endIndex from docs_get_document) |
| W |
| Replace every occurrence of a string in the whole document (body, headers, footers, footnotes) — plain substring match, no regex |
| D |
| Delete body content between two indexes [start_index, end_index) — irreversible |
| W |
| Insert a rows x columns table at a body index (a newline is inserted before it, so the table starts at index+1) and fill its cells from a 2- |
| W |
| Replace the body under a heading (up to the next heading of the same or higher level, or the document end) with plain text in one atomic edi |
| W |
| Set paragraph spacing, line spacing, alignment or named style on an index range [start_index, end_index) or on the body of a section by head |
| W |
| Run raw documents.batchUpdate requests — the full Docs API surface: insertText{location:{index},text}, deleteContentRange{range}, replaceAll |
| R |
| Export a Google Doc through Drive as PDF, plain text, HTML, Markdown or .docx |
| R |
| List a Google Doc's comments: id, author, content, quotedText, resolved, createdTime, replyCount (include_replies adds the replies) |
| W |
| Add a comment to a Google Doc |
| W |
| Reply to a comment on a Google Doc (comment_id from docs_list_comments); resolve=true also marks the comment resolved. |
Tool | Mode | Scope | What it does |
| R |
| Search messages with Gmail query syntax and return a compact list (id, threadId, date, from, to, subject, snippet, labelIds) + nextPageToken |
| R |
| Read one message by id |
| R |
| Get a whole conversation thread by threadId: every message in order |
| R |
| Download an attachment (attachmentId from gmail_read_message) |
| R |
| List all labels: system ones (INBOX, UNREAD, STARRED, SENT, DRAFT, SPAM, TRASH, IMPORTANT, CATEGORY_*) and user labels (id like Label_123) |
| R |
| Get one label with its counts (messagesTotal, messagesUnread, threadsTotal, threadsUnread) and visibility settings |
| W |
| Create a user label |
| W |
| Add/remove labels on ONE message |
| W |
| Add/remove labels on up to 1000 messages in one call (messages.batchModify) |
| W |
| Add/remove labels on every message of a thread (threads.modify) |
| D |
| Move a message to Trash (auto-deleted permanently after 30 days; undo with gmail_untrash_message). |
| W |
| Restore a message from Trash. |
| W |
| Create a draft (does NOT send) |
| W |
| Replace a draft's content (drafts.update) |
| R |
| List drafts (newest first) with draftId, messageId, threadId, to, subject, date, snippet |
| R |
| Read a draft in full: { draftId, message: {to, cc, bcc, subject, body, attachments, threadId, …} }. |
| D |
| Permanently delete a draft (irreversible; drafts do not go to Trash). |
| D |
| Sends an existing draft |
| D |
| Sends email immediately as the user |
| R |
| The signed-in mailbox: emailAddress, messagesTotal, threadsTotal, historyId. |
Tool | Mode | Scope | What it does |
| R |
| List all calendars the user has (calendar list: own, subscribed, shared, secondary) — the way to get calendar ids |
| R |
| List/search events in a calendar (events.list) |
| R |
| Get one event by id with its full description, attendees and response statuses, Meet link, recurrence and reminders. |
| W |
| Create an event |
| W |
| Update an event (PATCH: only the fields you pass change) |
| D |
| Delete an event (irreversible) |
| W |
| Create an event from natural-language text (events.quickAdd), e.g |
| W |
| Move an event to another calendar (events.move) |
| W |
| RSVP to an invitation as the signed-in user: sets your attendee responseStatus (accepted / declined / tentative / needsAction) and optional |
| R |
| Free/busy intervals for one or more calendars in a time window (freeBusy.query) |
| R |
| List the individual occurrences of a recurring event (events.instances) |
| R |
| Color palette for events and calendars (colors.get): maps color id → background hex, so color_id values for calendar_create_event/calendar_u |
Tool | Mode | Scope | What it does |
| R |
| List all Google Tasks lists (to-do lists) of the account — the way to get tasklist ids |
| W |
| Create a new task list |
| W |
| Rename a task list (title is the only editable field). |
| D |
| Delete a task list and every task in it (irreversible) |
| R |
| List tasks in a task list (flat, ordered like the UI: top-level tasks by position, each followed by its subtasks; subtasks carry |
| R |
| Get one task by id: title, notes, status (needsAction|completed), due (YYYY-MM-DD), completed time, parent, position, links. |
| W |
| Create a task |
| W |
| Update a task's title, notes, due date and/or status (PATCH — omitted fields are untouched) |
| W |
| Mark a task as completed (Google records the completion time) |
| W |
| Reopen a completed task (status back to needsAction, completion time cleared) |
| W |
| Move a task: re-nest it under |
| D |
| Delete a task permanently (irreversible; its subtasks are deleted too) |
| D |
| Clear all completed tasks from a list: they become hidden (still retrievable with show_hidden=true, and can be reopened), not deleted. |
Tool | Mode | Scope | What it does |
| R |
| Search the user's contacts by name, email, phone, nickname or organization (prefix match on words) |
| R |
| List all of the user's contacts (people/me/connections), paginated |
| R |
| Get one contact by resource name ('people/c…') with all supported fields (names, emails, phones, organization, addresses, birthday, notes, u |
| W |
| Create a contact |
| W |
| Update a contact |
| D |
| Permanently delete a contact by resource name ('people/c…') |
| R |
| List contact groups (labels): resourceName ('contactGroups/…'), name, groupType (USER_CONTACT_GROUP or SYSTEM_CONTACT_GROUP such as myContac |
| W |
| Add and/or remove contacts ('people/c…') in a contact group ('contactGroups/…') |
| R |
| Get up to 200 contacts by resource name ('people/c…') in one call |
Tool | Mode | Scope | What it does |
| R |
| List Chat spaces (rooms, group chats and direct messages) the signed-in user is a member of |
| R |
| Get one Chat space by resource name (spaces/AAAA): display name, type, threading/history state, member count, spaceUri |
| R |
| Find the existing direct-message space between the signed-in user and another user, by email address or users/{id} |
| R |
| List messages in a space (spaces/AAAA), newest first by default |
| R |
| Get one message by resource name (spaces/AAAA/messages/BBBB): text, sender, thread, attachments, quoted message, reactions |
| D |
| Send a message to a space as the signed-in user |
| W |
| Edit the text of a message you sent (spaces/AAAA/messages/BBBB) |
| D |
| Delete a message (spaces/AAAA/messages/BBBB) — irreversible |
| W |
| Add an emoji reaction (unicode emoji such as 👍 or ✅) to a message as the signed-in user |
| W |
| Upload a file (base64 content) as a Chat attachment for a space |
Tool | Mode | Scope | What it does |
| R |
| Presentation overview: title, locale, page size, revisionId, slide count, layouts (objectId + display name — needed for custom layouts) and |
| R |
| Cheapest way to read a deck: per slide {index, objectId, title (first TITLE/CENTERED_TITLE placeholder), text (all shape/table text joined w |
| R |
| One slide (page) in full detail: every element with objectId, type, placeholder, text (tables as cells[][], groups as children[]), transform |
| R |
| PNG thumbnail of a slide: returns a contentUrl (valid ~30 minutes, no auth needed to fetch) plus width/height |
| W |
| Create a new Google Slides presentation / slide deck (default theme, one blank title slide) |
| W |
| Append (or insert at insertion_index) a slide using a predefined layout and fill its title/body placeholders and speaker notes in one go |
| W |
| Insert text into a shape or table cell at a character index (0 = start; text inserted at the end must use the current length — read it with |
| W |
| Replace every occurrence of a string across the deck (or only on the given slides) — the standard way to fill a template |
| D |
| Delete a page element (shape, image, table, line, group) or a whole slide by objectId |
| W |
| Run raw presentations.batchUpdate requests — the full Slides API surface: createSlide, insertText, deleteText, replaceAllText, createShape ( |
| R |
| Export a presentation through Drive as plain text (default — all slide text, cheap), PDF or .pptx |
Tool | Mode | Scope | What it does |
| R |
| Get a form's structure: title, description, responderUri (public link), linked responses sheet, publish state and every item (itemId, questi |
| R |
| List responses to a form, newest first |
| R |
| Get one response by responseId (from forms_list_responses), with answers keyed by question title. |
| W |
| Create a Google Form with an optional description and questions (short_text, paragraph, multiple_choice, checkboxes, dropdown, linear_scale, |
| W |
| Append questions to an existing form (same question shape as forms_create_form) |
| W |
| Update a form's title and/or description (the Drive file name is unchanged — rename it via Drive). |
| D |
| Delete an item (question, section break, text, image, video) by its 0-based index (see forms_get_form item order) |
| W |
| Publish/unpublish a form and open/close it for responses |
| W |
| Run raw forms.batchUpdate requests for anything the simpler tools don't cover: createItem (any Item incl |
Tool | Mode | Scope | What it does |
| R |
| List albums in the user's Google Photos library that were created by this app (excludeNonAppCreatedData=true) |
| R |
| Get one album by id (must have been created by this app) |
| W |
| Create a new (empty) album in the user's Google Photos library |
| R |
| Search/list media items created by this app (mediaItems:search) |
| R |
| Get one media item by id (must have been created by this app) |
| W |
| Upload a photo/video into the user's Google Photos library (optionally into an app-created album) |
| W |
| Add existing media items (max 50 per call) to an album |
| W |
| Update the description (caption) of a media item created by this app (PATCH mediaItems/{id}?updateMask=description) |
| R |
| Start a Google Photos Picker session — the ONLY way to reach photos the user did not upload through this app |
| R |
| Get a Picker session's state |
| R |
| List the media items the user selected in a Picker session (only after mediaItemsSet=true) |
| R |
| Download the bytes of a picked media item (authenticated GET of mediaFile.baseUrl + '=d') |
| R |
| Delete / close a Photos Picker session when done with it (cleanup; frees the selection; the user's library is untouched) |
Tool | Mode | Scope | What it does |
| R |
| List the YouTube channels owned by the signed-in account (channels.list mine=true): id, title, customUrl, subscribers, views, video count an |
| R |
| Get one channel by channel_id (UC…), for_handle (@handle, e.g |
| R |
| List playlists of a channel (channel_id) or of the signed-in account (mine=true, the default when channel_id is omitted) |
| R |
| List the videos in a playlist (playlistItems.list): videoId, title, position, publishedAt, channelTitle, url |
| R |
| Get details + statistics for up to 50 videos in one call (videos.list): title, description, channel, publishedAt, duration (ISO 8601 + secon |
| R |
| Search YouTube (search.list) for videos, channels or playlists |
| R |
| List channels the signed-in account subscribes to (subscriptions.list mine=true): channelId, title, description |
| R |
| List top-level comment threads on a video (commentThreads.list): id, author, text, likes, publishedAt, replyCount |
Tool | Mode | Scope | What it does |
| W |
| Create a new Google Meet space (a meeting link) |
| R |
| Get a Meet space by name, meeting code or meet.google.com URL: link, code, config (accessType, entryPointAccess, moderation) and the active |
| W |
| Update a Meet space's config (only the fields you pass are changed): access_type, entry_point_access, moderation (ON = host must approve par |
| D |
| End the active conference (kick everyone out) in a Meet space you created |
| R |
| List past/ongoing conference records (each meeting occurrence), newest first |
| R |
| Get one conference record by resource name ('conferenceRecords/…'): space, startTime, endTime, expireTime. |
| R |
| List participants of a conference record: type (signedinUser | anonymousUser | phoneUser), displayName, user (people/… id for signed-in us |
| R |
| List recordings of a conference record: state (STARTED | ENDED | FILE_GENERATED), start/end time, driveFileId (the MP4 in Drive) and expor |
| R |
| List transcripts of a conference record: state, start/end time, docsDocumentId (the Google Doc holding the transcript) and exportUri |
| R |
| Read the spoken entries of a transcript ('conferenceRecords/…/transcripts/…'), in order |
| R |
| List Gemini 'take notes for me' smart notes of a conference record: state, docsDocumentId (the Google Doc with the notes) and exportUri |
Notes:
gmail_send_message/gmail_send_draft/chat_send_messagerequireconfirm: trueand are documented for the model as only when the user explicitly asks to send; everything else mail-related creates drafts.sheets_batch_update_spreadsheet,docs_batch_update_document,slides_batch_update_presentation,forms_batch_update_formtake raw Googlerequestsarrays — the full API surface (formatting, borders, colors, insert/delete rows, conditional formats, charts …).google_api_requestcalls anyhttps://*.googleapis.comendpoint with your token for the long tail.
What each count means
One number, one meaning — a tool count and a group count are never reported under the same name.
Number | Where it is reported | What it counts |
|
| Tools this deployment may CALL: the enabled groups and scopes, minus write tools when |
|
| Tools |
|
| Tool groups this deployment registers at least one tool from, Meta included. The same 14 the line above counts. |
per-group |
| The same two numbers, for one group. They sum to the totals. |
A count seen in a client that does not match these is describing a different deployment — production and a QA or staging worker run different builds on purpose. google_whoami and google_list_tools both return serverVersion; quote it when comparing numbers. tests/counts.test.ts pins every one of these to the others across disabled groups, a compact surface and a read-only deployment.
The table above is the full surface — what
tools/listadvertises by default.TOOL_SURFACE=compactadvertises a 19-tool recipe set instead (16 product tools across Gmail, Drive, Docs, Calendar, Sheets and Tasks, plus the threegoogle_*meta tools); every other tool stays registered and callable by name, and/healthreportssurface,toolsListedandtoolsCallable. The default is unchanged in this release.Responses drop empty fields; lists come back as
{count, items, nextPageToken}.
Renamed in 1.5
Tools renamed to one <group>_<verb>_<noun> grammar. No tool name stops working: every old
name stays callable as a hidden alias — it is not advertised in tools/list, and its result
carries a deprecated: {alias, use} marker. A connector added before 1.5 keeps working on its
cached names. Re-add the connector (Settings → Connectors) to see the new ones. One alias is not a
pure rename and inherits two of its target's defaults — the note under the table says which.
46 tools were renamed in 1.5. Every old name stays callable as a hidden alias (it is not in tools/list, and the result carries a deprecated marker) until it is removed in 2.0.
Old name (alias) | New name | Removed in |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
|
| 2.0 |
Not a pure rename: drive_list_folder. It was a thin wrapper over drive_search_files, so the alias clears the free-text query and keeps the old folder_id default ("root" = My Drive root). Two defaults it cannot restore differ: page_size is 25 (was 50) and order_by is modifiedTime desc (was folder,name). Everything else passes straight through — see the CHANGELOG entry for 1.5 PR-4.
How auth works
claude.ai connector (src/oauth.ts)
Claude ──(1) GET /authorize?client_id&code_challenge──▶ Worker consent page ("Claude wants to connect…", CSRF cookie)
◀─(2) POST /authorize → 302 accounts.google.com (scope=…, access_type=offline, prompt=consent, state=uuid)
Google ──(3) GET /callback?code&state ────────────────▶ Worker: exchange code → refresh_token + userinfo(email) → ALLOWED_EMAILS
◀─(4) completeAuthorization(props={email, refreshToken, grantedScopes}) → 302 claude.ai/…?code=
Claude ──(5) POST /token (PKCE verifier) ─────────────▶ MCP access token (1h) + refresh token
Claude ──(6) POST /mcp Authorization: Bearer ────────▶ OAuthProvider decrypts props → McpAgent → GoogleClientThe refresh token is stored only in the grant props, which
workers-oauth-providerencrypts at rest (the key is wrapped by the tokens given to the client) — nothing in KV is usable on its own.Google access tokens are cached AES-GCM-encrypted under a key derived from the refresh token and refreshed ~60 s before expiry; a revoked/expired refresh token surfaces as a "remove and re-add the connector" error instead of a 500.
bearer worker (src/index.ts)
you ──(1) GET /google/auth?key=<MCP_AUTH_TOKEN> ──▶ 302 accounts.google.com (state=uuid, 10 min)
Google ──(2) GET /callback?code&state ───────────────▶ exchange → userinfo → ALLOWED_EMAILS → TOKEN_KV["owner:grant"] = AES-GCM(refresh token)
client ──(3) POST /mcp Authorization: Bearer <MCP_AUTH_TOKEN> ▶ McpAgent → loadOwnerGrant → GoogleClientEach MCP session is a Durable Object; the encrypted KV token cache means many sessions share one Google access token instead of each minting their own.
Configuration
Name | Kind | Worker | Purpose |
| secret | both | The GCP OAuth 2.0 Web application client |
| secret | bearer | Shared secret MCP clients send; also unlocks |
| secret | bearer | Optional: refresh token minted elsewhere (skips |
| var | both | Comma-separated emails / |
| var | both |
|
| var | both | Optional Workspace domain pre-selected on Google's account chooser ( |
| var | both | Least privilege: comma-separated product groups ( |
| var | both | Which tools |
| var | both | Comma/space-separated extra tool names to advertise on top of a |
| var | both | Tool calls per minute per MCP session (default |
| var | both |
|
|
| oauth | Per-IP limit (30/min) on |
| KV binding | oauth | OAuth clients/grants/tokens, login state, encrypted Google token cache |
| KV binding | bearer | Encrypted owner grant, login state, encrypted Google token cache |
| Durable Object | both |
|
Production checklist
Everything below is either enforced by the code or visible in GET /health (warnings is the field to alert on). Full runbook — deploy, rollback, rotation, revocation, incidents: docs/OPERATIONS.md.
ALLOWED_EMAILSset to the accounts /@domainsthat may connect (/health.allowList = "set");ALLOW_ANY_GOOGLE_ACCOUNTleftfalse.Consent screen In production — Internal for a Workspace org (no verification), or External + published (expect the unverified-app interstitial and the 100-user cap until Google verification; restricted scopes
gmail.modify/drivealso need a CASA assessment). Never leave it in Testing: refresh tokens die after 7 days.Least privilege:
ENABLED_TOOL_GROUPS/DISABLED_TOOL_GROUPS(group keys or a profile) limited to what the audience needs;MCP_READONLY=truefor read-only audiences.TOOL_SURFACEpinned explicitly on any deployment whose client drives off the advertised list.Secrets only via
wrangler secret put/ repo Actions secrets;MCP_AUTH_TOKEN≥ 32 random chars (openssl rand -hex 32).AUTH_RATE_LIMITbinding present on the connector (/healthwarns if not);TOOL_RATE_LIMIT_PER_MINsized for your users.Deploy through CI (
deploy.yml) withmainprotected (PR + CI + CODEOWNERS review); rollback isnpx wrangler rollback.Workers Logs on (
observability.enabled, source maps uploaded); alert on/health.warnings,tool_callerror rate,auth_rate_limitedbursts, Google 429s.Privacy policy URL (
/privacy) and homepage (/) registered on the consent screen's branding.Dependabot + secret scan (gitleaks) +
npm auditrunning in CI (all in this repo).
Development
cp .dev.vars.example .dev.vars # fill in the Google client (add http://localhost:8787/callback + http://localhost:8788/callback as redirect URIs)
npm run dev # bearer worker on http://localhost:8787
npm run dev:oauth # connector worker on http://localhost:8788
npm run typecheck && npm test # tsc + vitest (no network; fetch is mocked)
npm run gen # refresh generated files after changing tools (README tables + docs/measurements/current.md)
npm run measure # what a client pays on connect: tools/list bytes per surface (see docs/measurements/README.md)Layout and conventions for contributors/agents: CLAUDE.md. Security model: SECURITY.md.
Troubleshooting
Symptom | Cause / fix |
A tool's new parameter (e.g. | claude.ai caches the connector's tool list from when it was added. Server-side defaults still apply (bodies are capped at 50k regardless); to see the new schema, disable/enable or remove/re-add the connector in Settings → Connectors. |
Claude still calls a pre-1.5 tool name (or you scripted one) | The old names keep working as hidden aliases until 2.0 — the call succeeds and the result carries |
Consent page button disabled / | Secrets not set on that worker: |
Google: | The OAuth client must list the exact |
Google: | An API's scope isn't valid for your client type or was removed (see Scopes) — the requested list is on the landing page |
Google: "Access blocked: app has not completed verification" with no Advanced link | The consent screen is External + Testing with you not listed as a test user, or a restricted scope on a client from an org with strict policies. Publish to production (or make the app Internal). |
claude.ai: "Unable to connect" / connector never reaches the consent page |
|
Tool error | Enable that API in the GCP project ( |
Tool error | You unticked that permission at sign-in — remove + re-add the connector (or re-run |
| Token revoked (myaccount.google.com/permissions), password/2FA change, or a Testing-mode app's 7-day expiry — reconnect; publish the app |
Bearer worker: | Open |
Chat tools 403 | Add the Chat app configuration under Google Chat API → Configuration |
Everything worked, then Claude asks to reconnect after ~30 days | The provider's refresh-token TTL; reconnecting is one click |
License
MIT
This server cannot be deployed
Maintenance
Related MCP Connectors
Permissioned access to Gmail, Drive and Calendar via the user's own Google account
Permissioned access to Outlook, OneDrive and Teams via the user's own Microsoft account
OAuth access to owned products, lead search, parcel search, and stored property audits.
OAuth 2.1 short-link tools for AI agents with scoped tokens, approvals, audit logs, and revocation.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables creating and reading Google Sheets in your personal Drive using OAuth authentication. Files are owned by you, not a service account.MIT
- FlicenseNot gradedqualityDmaintenanceEnables appending text to Google Docs and creating Gmail drafts via OAuth 2.0.-
- AlicenseAqualityDmaintenanceEnables sending emails and creating drafts through the Gmail API with OAuth 2.0 authentication.27 npmMIT
- FlicenseNot gradedqualityBmaintenanceEnables multi-user access to Google Workspace services (Gmail, Calendar, Docs, Sheets, Slides, Drive) via remote MCP with OAuth 2.1 authentication.-