Skip to main content
Glama

google-workspace-mcp

CI Surface checked by mcp-surface License: MIT Node

An MCP server that gives Claude write access to a personal Google account — Gmail, Drive, Calendar, Sheets, and YouTube — backed by a self-owned Google Cloud OAuth client rather than a hosted third-party connector. It can send real email, not just draft it.

23 tools across 5 Google APIs. Runs locally over stdio.

You:    email me a summary of today's calendar
Claude: [calendar_list_events] → 4 events
        [gmail_send_email]     → delivered

Why own the OAuth client

The hosted Google connectors are read-mostly. Making Claude act — send a real email, create a real invite, write a real spreadsheet cell — means holding the OAuth client yourself. That single decision drives most of what's interesting below.

Related MCP server: gsheets-mcp

Design notes

Least-privilege scopes, chosen to avoid a paid security review

Google classifies gmail.readonly and drive.readonly as Restricted scopes. Shipping an app that uses them requires a CASA security assessment — a paid, recurring third-party audit. That is not a sensible cost for a single-user personal tool, so this server requests none of them. For Gmail and Drive, that means it can act but not read:

Scope

Why

gmail.send, gmail.compose

Send and draft. Never reads the inbox.

drive.file

Can only touch files it created itself — cannot read pre-existing Drive content.

calendar.events, calendar.readonly

Calendar is not a restricted scope.

spreadsheets

Read and write sheets by ID.

youtube.force-ssl

Playlists, subscriptions, comments, ratings.

Reading arbitrary Gmail and Drive content is left to the existing claude.ai connectors. The two halves compose: read there, act here.

Arguments are untrusted, even though the caller is your own assistant

Every argument to these tools is model output, and a model that has just read a web page or an email can be steered by text it found there. A server that sends mail and writes spreadsheets has to treat its inputs the way a web server treats a form post:

  • Header injection. Subjects, addresses and attachment filenames are interpolated into the MIME message. A line break in any of them would start a new header — Subject: Hi\r\nBcc: someone@example.com quietly copies the mail to a stranger — so line breaks are refused with an error rather than stripped. Non-ASCII subjects and filenames are encoded per RFC 2047 and RFC 2231 instead of being written raw.

  • Formula injection. Sheets' USER_ENTERED mode parses a string as if it were typed, so =IMPORTDATA("https://…"&A1) becomes a live formula that can post the sheet's contents to any URL. sheets_write_range writes RAW by default; formula parsing is an explicit valueInputOption the tool description tells the model never to use for text it read elsewhere.

  • Bodies survive transport. Generated HTML is often one very long line, and SMTP caps lines at 998 octets. Every body part is base64-encoded so no relay has a reason to break it inside a tag.

Each of these has a test that builds the hostile input and asserts on the bytes that would go to Google.

Irreversible tools say so in their own descriptions

A tool description is the only thing the model reads before deciding to call it, so the warnings live there rather than in a confirmation layer the model can route around. From src/gmail.js:

Sends an email immediately from the authorized Gmail account, optionally with file attachments. This is a real send with no draft/review step and cannot be undone.

Every tool also declares MCP tool annotations — readOnlyHint, destructiveHint, idempotentHint — so a client can auto-approve the reads and prompt for the rest.

Several tools are externally visible under your real identity: gmail_send_email delivers to real inboxes, calendar_create_event emails invitations, and the YouTube comment tools post publicly. They are ordinary tools — but they're the ones where a mistake is visible to other people.

Calendar invitations are actually sent

Google's Calendar API emails no one unless the request says otherwise, so "invite alice@example.com" used to produce an event Alice never heard about unless she happened to use Google Calendar. The create, update and delete tools now send sendUpdates: "all" by default — matching what their descriptions promise — and take sendUpdates: "none" when a change shouldn't notify anybody.

The refresh token expires every ~7 days — this is expected

The OAuth client sits in Google's Testing publishing mode, and Google invalidates refresh tokens for testing-mode apps after 7 days of inactivity. Auth breaking after a week away is normal maintenance, not a bug:

npm run check-auth   # is the stored token still good?
npm run authorize    # re-consent in the browser; rewrites credentials/token.json

Moving to "In production" would remove the expiry but triggers exactly the verification review the scope choices above were made to avoid. For a single-user setup, periodic re-authorization is the cheaper trade.

The consent flow uses PKCE and a state check, and its callback listener binds to loopback only.

Refreshed tokens are persisted

google-auth-library emits a tokens event when it silently refreshes an access token. src/auth.js listens and merges the result back to disk, so a long-lived session doesn't lose its refresh token across restarts.

Setup

Requires Node 22+.

1. Create the OAuth client

  1. In the Google Cloud Console, create a project.

  2. Enable the Gmail, Google Calendar, Google Drive, Google Sheets, and YouTube Data v3 APIs.

  3. Under APIs & Services → OAuth consent screen: External, publishing status Testing, and add your own Google account under Test users.

  4. Under Credentials, create an OAuth client ID of type Desktop app.

  5. Download the JSON to credentials/client_secret.json.

credentials/ is git-ignored. See credentials/client_secret.example.json for the expected shape.

2. Authorize

npm ci
npm run authorize

Opens a loopback listener on localhost:53682, prints a consent URL, and writes the refresh token to credentials/token.json. Google shows an "unverified app" warning — expected for a Testing-status client only you can use.

3. Register with your client

Claude Code:

claude mcp add google-workspace --scope user -- node /absolute/path/to/google-workspace-mcp/src/index.js

Claude Desktop (claude_desktop_config.json):

{
  "mcpServers": {
    "google-workspace": {
      "command": "node",
      "args": ["/absolute/path/to/google-workspace-mcp/src/index.js"]
    }
  }
}

Tools

Gmail

Tool

Effect

Purpose

gmail_send_email

writes

Send a real message — delivered immediately, not a draft

gmail_create_draft

writes

Compose without sending

gmail_list_drafts

read-only

List existing drafts

Calendar

Tool

Effect

Purpose

calendar_list_events

read-only

Events in a time range

calendar_create_event

writes

Create an event; emails attendees unless sendUpdates says otherwise

calendar_update_event

writes

Modify an existing event

calendar_delete_event

destructive

Remove an event and send cancellations

Drive / Sheets

Tool

Effect

Purpose

drive_create_file

writes

Create a file in Drive

sheets_read_range

read-only

Read an A1-notation range

sheets_write_range

destructive

Overwrite an A1-notation range (RAW unless told otherwise)

YouTube

Tool

Effect

Purpose

youtube_search

read-only

Search videos, channels, playlists

youtube_get_video / youtube_get_channel

read-only

Metadata and statistics

youtube_list_playlists / youtube_list_playlist_items

read-only

Playlist contents

youtube_get_comments / youtube_list_subscriptions

read-only

Comment threads, subscriptions

youtube_create_playlist / youtube_add_to_playlist

writes

Build playlists

youtube_post_comment / youtube_reply_to_comment

writes

Post publicly

youtube_rate_video / youtube_subscribe

writes

Like / dislike / clear, subscribe

The exact schemas the model sees are in mcp-surface.json.

Development

npm test               # unit tests — no network, no credentials
npm run lint           # ESLint
npm run surface        # start the server and diff its tool surface against mcp-surface.json
npm run surface:update # re-record mcp-surface.json after an intended change

The tests register every tool, assert the MIME builder's output byte-for-byte where it matters (header injection, encoded words, line lengths, boundaries), and check the requests that would go to Google — sendUpdates, valueInputOption, timeouts — by handing the API clients an auth object that records each request instead of sending it.

npm run surface runs mcp-surface against the real server, the way a client connects. CI fails if the advertised tools differ from the committed snapshot in any way, so a renamed tool, a reworded description or a dropped annotation has to show up as a reviewed diff. It also holds the definitions to a 16 KB budget, since they are resent on every request.

Layout

src/auth.js            OAuth client construction, token load/refresh persistence
src/shared.js          version, request timeout, tool-result helpers
src/gmail.js           MIME assembly (headers, encoding, attachments) + gmail_* tools
src/calendar.js        calendar_* tools
src/drive.js           drive_* tools
src/sheets.js          sheets_* tools
src/youtube.js         youtube_* tools
src/index.js           McpServer construction + stdio transport
scripts/authorize.js   OAuth consent flow (PKCE, state, loopback-only listener)
scripts/check-token.js token health check
scripts/smoke-test.js  live end-to-end send to the account's own address
test/tools.test.js     offline tests
mcp-surface.json       the committed tool surface CI compares against

Auth files

Both live in credentials/, which is git-ignored and checked by CI:

  • client_secret.json — the OAuth client, downloaded from Google Cloud Console

  • token.json — access + refresh tokens, written by npm run authorize

Neither has ever been committed. If credentials/ is lost, re-download the client secret and re-run npm run authorize.

Notes

  • Each Google API comes from its own @googleapis/* package rather than the monolithic googleapis, which is now over 240 MB because it bundles every Google API there is. Five APIs need about 7 MB; node_modules went from 282 MB to 45 MB.

  • Every Google request has a 30-second timeout, so a stalled connection fails the tool call with an error instead of hanging it.

  • Scopes are requested up front at authorize time. Adding a new API later means re-running npm run authorize so the new scope is granted.

Privacy policy

Google requires a published policy on the consent screen even for a Testing-status app: brettadams0.github.io/google-workspace-mcp-policy (source).

License

MIT — see LICENSE. Changes are recorded in CHANGELOG.md.

Related MCP Connectors

Related MCP Servers