Skip to main content
Glama
README.md
# gws-mcp

A small local MCP server that gives a coding agent (I use it with Claude Code) controlled
access to one Google account: Gmail, Calendar and Drive. It's two Python files, 21 tools,
and it runs over stdio from the user's own OAuth token.

I built it because the hosted Google connectors loaded in one Claude Code surface and
never in another. That surface's session token lacked the MCP scope, so the connectors
silently weren't there, even while the settings page said "Connected". Running my own
server removes that dependency, and it let me enforce rules in code that the hosted
connector could not.

---

## The rules it enforces

**A draft can't send.** `gmail_create_draft` calls `drafts.create` and nothing else. A
hosted connector's "create draft" tool once sent four emails I had not reviewed. Sending
is two separate tools, both marked destructive, and every send returns a reminder that
SENT is not DELIVERED: check for a bounce before reporting success.

**Every tool declares what it can do.** Each tool carries MCP annotations: 11 are
read-only, 5 write, and 5 are destructive (send, trash, delete). The client can gate on
them, and the agent's instructions say any send or calendar write needs the user's yes
in the same turn.

**One account, asserted.** The first call checks the token's address against
`$GWS_EXPECTED_ACCOUNT` and refuses if they differ. It never reads a different mailbox
quietly.

**Least-privilege scopes.** `gmail.modify` (no permanent delete), `calendar.events` +
`calendar.readonly` (it can't create, share or delete a calendar), and `drive.readonly`.

**Unknown is not empty.** A missing or broken credential raises an error that says to
report the source as UNKNOWN. An unreadable inbox must never look like an empty one.

**Errors carry their reason.** A bare "Error executing tool" can't be diagnosed, so every
failure is re-raised with its exception type and message.

## What production taught it

- **Parallel calls segfaulted the server.** The client fires tool calls in parallel, the
  SDK runs sync tools on worker threads, and the Google client objects aren't
  thread-safe. Two concurrent calls killed the process (exit 139, reproduced), and the
  client only saw a generic error for both. A re-entrant lock now makes calls queue.
- **Bursts hit Gmail's per-user rate limit.** Every request now retries 403/429/5xx with
  exponential backoff.
- **Thread search was slow.** Fetching 44 threads one by one took 19 seconds. They're now
  fetched in batches of 20, and any batch member that fails is retried singly, never
  dropped.
- **Output size is a cost.** A hosted connector returned 72 KB for two weeks of
  calendar. Here, list calls return trimmed summaries with descriptions cut to 160
  characters, and bodies are capped.
- **Plain-text links get rewritten.** Gmail wraps bare URLs in a redirect, so the send
  and draft tools take an `html_body` with real anchors.

## Tools

| Area | Tools |
|---|---|
| Gmail, read | `gmail_search_threads`, `gmail_get_thread`, `gmail_get_message` (optionally raw MIME), `gmail_download_attachment`, `gmail_list_labels`, `gmail_list_drafts` |
| Gmail, write | `gmail_create_draft`, `gmail_update_draft`, `gmail_modify_labels` |
| Gmail, destructive | `gmail_send_draft`, `gmail_send_message`, `gmail_delete_draft`, `gmail_trash` |
| Calendar | `calendar_list_calendars`, `calendar_list_events`, `calendar_get_event`, `calendar_create_event`, `calendar_update_event`, `calendar_delete_event` |
| Drive | `drive_search_files`, `drive_read_file` (Docs, Sheets and Slides come back as text) |

## Setup

1. In your own Google Cloud project, enable the Gmail, Calendar and Drive APIs and create
   a **Desktop app** OAuth client. Download its JSON.
2. Install and authorize:

```bash
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
export GWS_EXPECTED_ACCOUNT=you@example.com
export GWS_CLIENT_SECRET=~/path/to/client_secret.json
python gws_auth.py --url          # open the URL, approve, copy the failed localhost URL
python gws_auth.py --code '<URL>' # saves ~/.config/gws-personal/token.json (mode 600)
python gws_auth.py --status       # one REAL call per API; a file on disk proves nothing
```

3. Register it with Claude Code at user scope, so every session gets it:

```bash
claude mcp add -s user \
  -e GWS_EXPECTED_ACCOUNT=you@example.com -e GWS_TZ=Asia/Manila \
  gws -- "$PWD/.venv/bin/python" "$PWD/gws_mcp.py"
```

`python gws_mcp.py --selftest` checks MIME building (HTML + plain text, attachments,
reply threading) offline. `gws_auth.py --export` prints the token as one base64 line for
a cloud environment variable (`$GWS_PERSONAL_TOKEN`). Treat that line as a live
credential.

## Related

- [How I work with AI agents](https://ralphalejandrino.github.io/agents/): the agent
  setup this server is part of, with real sessions replayed.
- [ProjectCRM](https://github.com/ralphalejandrino/ProjectCRM): a CRM whose order intake
  puts a language model behind the same kind of guardrails.

## License

See [LICENSE](LICENSE).