google-workspace-mcp
by brettadams0
README.md
# google-workspace-mcp
[](https://github.com/brettadams0/google-workspace-mcp/actions/workflows/ci.yml)
[](https://github.com/brettadams0/mcp-surface)
[](LICENSE)
[](package.json)
An [MCP](https://modelcontextprotocol.io) 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.
## 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](https://developers.google.com/workspace/guides/configure-oauth-consent) — 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](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) — `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:
```bash
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](https://console.cloud.google.com/), 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
```bash
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:
```bash
claude mcp add google-workspace --scope user -- node /absolute/path/to/google-workspace-mcp/src/index.js
```
Claude Desktop (`claude_desktop_config.json`):
```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`](mcp-surface.json).
## Development
```bash
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](https://github.com/brettadams0/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](https://brettadams0.github.io/google-workspace-mcp-policy/) ([source](https://github.com/brettadams0/google-workspace-mcp-policy)).
## License
MIT — see [LICENSE](LICENSE). Changes are recorded in [CHANGELOG.md](CHANGELOG.md).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues