Skip to main content
Glama

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.

Related MCP server: AgentBridge

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:

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
  1. Register it with Claude Code at user scope, so every session gets it:

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.

  • How I work with AI agents: the agent setup this server is part of, with real sessions replayed.

  • ProjectCRM: a CRM whose order intake puts a language model behind the same kind of guardrails.

License

See LICENSE.

Related MCP Connectors

Related MCP Servers