Google Workspace MCP server (Gmail, Calendar, Drive) for Claude Code.
Built on him (REST ORM), the
official Ruby MCP SDK,
and Google's OAuth2 library (googleauth).
The built-in claude.ai Gmail/Calendar connectors have limited OAuth scopes —
no trash, no labels, no send, no Drive access. GMCP runs as a local stdio MCP
server with full gmail.modify, calendar.events,
calendar.calendarlist.readonly and drive.readonly scopes.
Gmail (9 tools)
gmail_search, gmail_get_message, gmail_list_labels, gmail_trash_message,
gmail_archive_message, gmail_label_message, gmail_send, gmail_create_draft, gmail_reply
Calendar (7 tools)
calendar_list_calendars, calendar_list_events, calendar_get_event,
calendar_create_event, calendar_update_event, calendar_delete_event, calendar_rsvp
Drive (3 tools)
drive_search, drive_list_folder, drive_read_file
- Go to https://console.cloud.google.com/
- Create a project (or use an existing one)
- Enable the Gmail, Google Calendar, and Google Drive APIs
- Under APIs & Services → Credentials, create an OAuth 2.0 Client ID
- Application type: Desktop app
- Download the JSON file and save it to
~/.config/gmcp/credentials.json - Under Google Auth Platform → Audience, click Publish app
Step 6 is not optional for day-to-day use. While the consent screen is in
Testing, Google expires every refresh token after 7 days, so each account
silently stops working about a week after you authorize it and the server reports
invalid_grant. Publishing to In production removes that expiry.
Publishing is not the same as verification. An unverified production app still
shows a "Google hasn't verified this app" interstitial (Advanced → Go to …)
on each authorization, and is capped at 100 users. Full verification of the
restricted gmail.modify scope requires an annual third-party security
assessment, which is not worth doing for a personal install — accept the
interstitial.
gem install gmcpOr from source:
git clone https://github.com/TwilightCoders/GMCP ~/.gmcp
cd ~/.gmcp && bundle installMCP servers are configured in .claude.json, not settings.json. Let the CLI
write it so it lands in the config directory your sessions actually read
($CLAUDE_CONFIG_DIR/.claude.json when that is set, ~/.claude.json otherwise):
GMCP=~/.gmcp # wherever you cloned it
claude mcp add gmcp --scope user \
--env "GMCP_ACCOUNTS=you@example.com,work@example.com" \
--env "GMCP_CAPABILITIES=gmcp.authorize,gmail.read,calendar.read,drive.read" \
--env "BUNDLE_GEMFILE=$GMCP/Gemfile" \
-- "$(which ruby)" "$GMCP/bin/gmcp"--scope user registers it for every session on the machine; --scope project
writes a .mcp.json next to a single repo, which is how you give one project a
wider grant than the default (see GMCP_CAPABILITIES below).
If you use a Ruby version manager, point the command at its shim rather than a
versioned binary, so a Ruby upgrade does not silently break every session. With
rbenv, add --env "RBENV_DIR=$GMCP" and the shim resolves the version from
GMCP's own .ruby-version, regardless of which directory the session runs in.
Verify with claude mcp get gmcp. On startup the server prints its accounts and
capabilities to stderr — that stderr is the only place an operator can see what a
given process was actually granted.
Ask Claude to call gmcp_authorize, or run bin/gmcp-auth <account> from a terminal. Either opens your browser and captures the redirect on a temporary local callback server — there is nothing to copy and paste. (Google retired the out-of-band code flow in 2022.)
Tokens are stored owner-only at ~/.config/gmcp/<account>/token.yaml, and access tokens refresh automatically while the server runs. If a refresh token is revoked, the server still starts — the account is left unbound and reports that it needs re-authorizing, rather than taking the whole server down.
GMCP is scoped along two independent axes. Both are environment variables read
once at process start, so a given bin/gmcp process has a fixed, inspectable
reach — it prints both to stderr on startup.
Comma-separated. Each account keeps its own OAuth token at
~/.config/gmcp/<account>/token.yaml, and every Gmail/Calendar/Drive tool takes
an optional account: argument selecting among them (defaulting to the first).
GMCP_ACCOUNTS=personal@example.com,work@example.com bin/gmcpAuthorize each one separately with gmcp_authorize(account: "...").
Comma-separated capability names from config/capabilities.yml. Capabilities
gate registration: a tool whose capability was not granted is never defined
on the MCP server, so it never appears in tools/list and cannot be called no
matter what the client sends.
# a read-only assistant
GMCP_CAPABILITIES=gmail.read,calendar.read bin/gmcp
# one that may also send
GMCP_CAPABILITIES=gmail.read,gmail.send bin/gmcp| Capability | Grants |
|---|---|
gmcp.authorize |
Start the OAuth flow (opens a browser) |
gmail.read |
Search (paginated), read, list labels, list attachments, inspect unsubscribe options |
gmail.modify_labels |
Add/remove labels and archive, single or batched (archiving is removing INBOX) |
gmail.trash |
Trash a message, single or batched |
gmail.send |
Send, draft, reply |
gmail.download |
Write attachment bytes to a local directory |
gmail.unsubscribe |
RFC 8058 one-click unsubscribe — leaves Google, tells a third party the address is live |
calendar.read |
List calendars, read events |
calendar.write |
Create, update, RSVP |
calendar.delete |
Delete an event |
drive.read |
Search, list folders, read files |
voice.read |
List/search Voice; report an account's Voice number |
voice.modify |
Mark a conversation read |
config/capabilities.yml is the source of truth; a spec asserts it matches the
tools actually registered, so it cannot silently drift.
gmail_search returns one line per message (id, date, sender, subject) and
paginates. It returns a page_token whenever more results exist; pass it back
to walk a whole mailbox. calendar_list_events, drive_search and
drive_list_folder page the same way. In Ruby, Message.each_page handles the
cursor for you and is bounded by max_pages so a runaway cursor cannot spin.
GMCP::Gmail::Message.each_page('in:inbox', max_results: 500) do |page|
page[:messages].each { |m| ... }
endActing on the result is batched — gmail_batch_archive, gmail_batch_trash,
and gmail_batch_modify take up to 1000 ids in one request rather than one
round trip per message. Gmail returns nothing per-message, so a partial failure
is not distinguishable; re-read if you need per-message confirmation.
Attachments are two steps: gmail_list_attachments (under gmail.read) gives
you filenames and part_ids, gmail_download_attachment (under
gmail.download) writes one to a directory you name. There is no default destination, the
filename is reduced to a basename so it cannot escape that directory, and an
existing file is never overwritten.
This distinction is the whole security boundary. It is not a convenience.
| Value | Meaning |
|---|---|
GMCP_CAPABILITIES not set |
Every capability. For a human running bin/gmcp by hand with no grant system in front of it. |
GMCP_CAPABILITIES="" |
No capabilities. The explicit empty grant set for an identity that was granted nothing. |
An automated launcher must always set the variable explicitly, including when the grant set is empty. Omitting the key because there are no grants fails open and hands the process everything. There is no wildcard value, and unknown entries are dropped rather than trusted, so a grant cannot widen itself by typo.
Models are bound to a resolver, not to a concrete API: him calls use_api
at request time if it responds to :call, so GMCP::ApiBinding installs a
lambda once and swaps the account per fiber via Thread.current.
Server.with_account scopes a binding to a block and restores whatever was
bound before, so two concurrent tool calls for different accounts cannot see
each other's APIs, and a nested call cannot leak its account to its caller.
A request with nothing bound raises ApiBinding::NotBound rather than falling
back to whichever account the previous caller happened to leave behind.
This was not always true. Until 0.3.0 the account was written onto the model class itself, so isolation depended on never running two requests at once — which is not isolation, only an absence of opportunity. Per-process separation was the recommendation because it was the only thing that actually held.
Capability separation is still per-process, because GMCP_CAPABILITIES is
read once at startup and decides which tools get registered at all. One process
per capability set; that process may now serve several accounts safely.
GMCP models gmail, google_calendar, drive, and voice as separate
connectors, served by one process. Each declares in config/capabilities.yml
whether GMCP_ACCOUNTS constrains it and where its credential lives:
| Connector | account_scoping |
credential_source |
GMCP_ACCOUNTS constrains it? |
|---|---|---|---|
gmcp |
enforced |
local_file |
yes |
gmail |
enforced |
local_file |
yes |
google_calendar |
enforced |
local_file |
yes |
drive |
enforced |
local_file |
yes |
voice |
enforced |
delegated (Chrome) |
yes |
Voice has no usable OAuth path — the token-to-cookie exchange is reserved for
Chromium — so GMCP::Voice::Session uses the Google session of the Chrome
profile signed in as the requested account, found by email in Chrome's profile
list. Voice tools take account: like the rest and are limited to
GMCP_ACCOUNTS; an account with no Chrome profile has no Voice. Chrome keeps
the session fresh as you use it, so there is no separate sign-in.
Chrome encrypts its cookies with a key in the macOS Keychain ("Chrome Safe Storage"). The first Voice call asks for access; choose Always Allow. Voice is macOS-only for that reason.
bundle install
bundle exec rake specRuby 3.4.9+ required.
MIT. See LICENSE.txt.