使用说明
Google Calendar & Gmail MCP Server
A Model Context Protocol (MCP) server that provides Google Calendar and Gmail integration for AI assistants like Claude.
Features
Calendar
- Multi-Calendar Support: List events from multiple calendars simultaneously
- Event Management: Create, update, delete, and search calendar events
- Recurring Events: Advanced modification capabilities for recurring events
- Free/Busy Queries: Check availability across calendars
- Smart Scheduling: Natural language understanding for dates and times
- Intelligent Import: Add calendar events from images, PDFs or web links
Gmail
- Eleven MCP tools: search and read messages, read the profile, modify messages (read state, archive, delete, labels), list, create and delete labels and filters, and apply existing filters to mail already in the mailbox; see Available Tools
- Multi-Account Support: Use multiple Google accounts with separate OAuth tokens
- Organization CLI: a set of root
.mjsscripts that label, filter and archive a large mailbox from config; see Email Organization CLI
Version history is in the CHANGELOG.
Quick Start
Prerequisites
- A Google Cloud project with the Calendar API and the Gmail API enabled
- OAuth 2.0 credentials (Desktop app type)
Google Cloud Setup
Create a Google Cloud project, enable the Calendar and Gmail APIs, and download a Desktop app OAuth client as gcp-oauth.keys.json. The step-by-step walkthrough, the scopes the server requests, and the test-user propagation delay are in the Authentication Guide.
Installation
Option 1: Use with npx (Recommended)
Add to your Claude Desktop configuration:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"google-calendar": {
"command": "npx",
"args": ["@cocal/google-calendar-mcp"],
"env": {
"GOOGLE_OAUTH_CREDENTIALS": "/path/to/your/gcp-oauth.keys.json"
}
}
}
}
⚠️ Important Note for npx Users: When using npx, you must specify the credentials file path using the GOOGLE_OAUTH_CREDENTIALS environment variable.
Option 2: Local Installation
git clone git@github.com:integritystudio/bot-army-google-calendar-mcp.git
cd bot-army-google-calendar-mcp
npm install
npm run build
Then add to Claude Desktop config using the local path or by specifying the path with the GOOGLE_OAUTH_CREDENTIALS environment variable.
First Run
Start Claude Desktop; on first use the server opens the Google consent flow in your browser. The steps are under First-Time Authentication.
Multiple Accounts
Each Google account authenticates under its own key in one token file; GOOGLE_ACCOUNT_MODE selects which one the server uses. Setup, switching and verification are in Advanced Usage.
Re-authentication
Test-mode tokens expire after 7 days. Claude Desktop normally reopens the browser flow itself; if you see authentication errors, re-run the auth command for your install type as described in the Authentication Guide, which also covers publishing the app to stop the weekly expiry.
Gmail CLI Tokens
The root .mjs organization scripts use their own token file. Authenticate them with npm run auth:gmail and check status with node switch-account.mjs. Scopes, storage location and the per-account variants are in the Authentication Guide.
Email Organization CLI
The root .mjs scripts label, filter and archive a large mailbox from three data-only configs under config/. Routing truth is config/categories.mjs (inspect the live plan with node create-filters.mjs --dry-run); Organization/* and Country/* sender tags come from config/org-tags.mjs and config/country-tags.mjs. Every script, flag and shared lib/ module is described once, in CLAUDE.md. Read Known Issues below before any bulk label operation.
Known Issues
Sharp edges found the hard way. Each one fails silently — the script reports success while doing nothing, or does the wrong thing without erroring.
ORG_TAGS must contain no time-varying data
ORG_TAGS in config/org-tags.mjs is long-lived routing config.
Anything in it that expires rots silently, because nothing re-reads the source to notice.
An org's schema.event therefore lists recurring programmes, not dated instances —
what kinds of event the org runs is stable; which edition runs when is not. No
startDate/endDate, and no edition year in a programme name (ZoukMX Main Festival,
not ZoukMX Main Festival 2027). startDate is optional on schema.org Event, so a
dateless programme is still valid.
Historical dates inside DefinedTerm descriptions are fine — "the Houston Fusion
Exchange (January 2008)" is a fact about a dance form's origin, not a forecast. The terms
themselves live in lib/vocabularies.mjs; see
docs/DEFINED-TERMS-GUIDE.md.
To check: serialize ORG_TAGS and grep for ISO-date-shaped values.
node -e "import('./config/org-tags.mjs').then(m=>console.log(JSON.stringify(m.ORG_TAGS).match(/\"20[0-9]{2}-[0-9]{2}/g)||'none'))"
Parentheses in a label name break Gmail's label: operator
Gmail's query parser cannot match ( or ) in a label name even inside quotes.
label:"Organization/…/Council (BZDC)/ZoukMX" returns 0 against a label that
demonstrably holds mail (confirmed via labelIds, which bypasses the parser).
This silently breaks:
applyTagSet()'s-label:"…"backfill clause — re-tags everything on every run- any emptiness check guarding a label deletion — a parenthesised label reads as empty and gets deleted with mail still on it
Keep label names free of parentheses. An acronym belongs in schema alternateName, not
in the path.
A subject clause ORed onto a sender clause is unconstrained by that sender
protect-important-inbox.mjs's "Calendly Refunds & Support" entry used to read as
Calendly-scoped:
from:(support@calendly.zendesk.com OR invoice+statements@calendly.com)
OR subject:(refund OR "Added to a team")
It was not. The OR meant the subject clause matched on its own, with no sender
constraint at all — measured 410/410 of the query's matches came from the subject
half alone, and the sender half contributed 0. It labeled Amazon returns, Airbnb
refunds, and Experian marketing "Keep Important", none of which mention Calendly.
134 already-labeled messages carried the label for exactly this reason.
Fixed 2026-08-26. The subject clause is now AND'd with a Calendly sender
constraint — from:calendly.com subject:(refund OR "Added to a team") — so it can
only narrow the sender clause's own matches, never reach past them. The live
filter was deleted and recreated with the corrected criteria, and the 134
messages had Keep Important stripped (verified against the same three other
IMPORTANT_FILTERS rules first, so nothing independently entitled to the label
was touched). cappedSweep's truncation cap was still protecting against exactly
this shape of query while it was broken, which is why this file cannot be safely
folded into applyTagSet()'s unbounded backfill — see the comment above
IMPORTANT_FILTERS in the script.
An unquoted multi-word term breaks its whole OR group
subject:(late fee OR overdue OR "missed payment") does not mean what it reads as.
Gmail collapses it to late AND fee: it matched 8 messages where the quoted
spelling subject:("late fee" OR overdue OR "missed payment") matches 100. The
overdue and "missed payment" arms contributed nothing, silently — the query is
valid, returns results, and looks like it works.
Quote every multi-word term inside an OR group. A bare two-word term is two ANDed
tokens, and the OR binds to the second one.
This is how route-billing-mail.mjs ran its entire urgent-billing protection on one
of its three terms.
A negation must be scoped like the test it complements
subject:X and -"X" are not complements. The first tests the subject; the second
negates the term anywhere in the message, body included. A partition built from the
two leaks: a message can fall through both halves.
route-billing-mail.mjs paired subject:(...urgent...) with a hand-expanded
-"late fee" -overdue -"missed payment", and 149 billing messages matched neither
side — held out of the archive sweep because their bodies happened to mention an urgent
word. Write the complement as -subject:(...), from the same constant as the test, and
assert the two halves sum to the unpartitioned total.
searchAndModify() truncates to its caller's maxResults
searchAndModify() in lib/gmail-batch-utils.mjs does
page — it delegates to listAllMessageIds() (src/shared/gmail-core.ts), which follows
nextPageToken at 500 per request. The cap is entirely the caller's optional fourth
argument, maxResults, passed through as the listing limit. Fix the call site, not the
library.
Callers that truncate today:
| Call site | Cap | Deliberate? |
|---|---|---|
protect-important-inbox.mjs, route-billing-mail.mjs (6 sites) |
SUBJECT_SWEEP_CAP |
Yes — subject-only queries; cappedSweep() (in lib/gmail-batch-utils.mjs) says when it truncates |
create-filters.mjs (backfill sweep) |
category.maxResults |
Yes — opt-in per category, unset means page to exhaustion |
No accidental truncation remains. relabel-messages.mjs used to head this table: it
passed DEFAULT_MAX_RESULTS, so on a 7,000-message label it moved 100 and printed
Total relabeled: 100 — which reads as success. The inner Processed 50/61 lines came
from batch chunking of one page, not from list paging, so they gave false reassurance of
full coverage. It is now a modify-messages.mjs preset, which
selects with listAllMessageIds (unbounded) instead of searchAndModify. Because
lifting the cap makes an unbounded relabel a far bigger action than the capped one it
replaces, it now previews unless --yes.
archive-old-emails.mjs had the same cap by a different route — searchAndModifyOlderThan
defaulted maxResults, so dropping the argument would not have lifted it. That helper
had no other caller once the script migrated, and has been deleted.
Use strip-label.mjs for bulk removal; it is a
modify-messages.mjs preset, so it selects by labelIds and
collects every id before modifying anything — no page token is held across a mutation,
which is what its old re-query-the-first-page loop existed to avoid.
Never confirm a bulk relabel from a script's own count — re-query afterwards.
A Gmail filter can add only one user label
users.settings.filters.create rejects an action whose addLabelIds holds more than one
user label (Too many user labels in filter); system labels in removeLabelIds do
not count. To apply two labels on arrival, create one filter per label on the same query,
with only the first carrying the archive/mark-read action.
users.messages.modify has no such limit, which is why a backfill can succeed on a label
set that filter creation rejects.
resultSizeEstimate is not a count
messages.list returns resultSizeEstimate, and Gmail caps it at ~201 — so it is not
merely imprecise, it is a ceiling that any larger result set hits exactly. It reported 201
for both from:info@email.meetup.com (true count 10,475, off by 52×) and
from:news@alphasignal.ai (true count 433). Two unrelated senders reporting an identical
total is the tell.
The failure is silent and always under-reports, so anything sized from it — a coverage percentage, a "how much mail would this touch" check, a decision that a set is small enough to skip paging — is wrong in the direction that looks safe.
Use countMessagesMatching() in lib/gmail-message-utils.mjs,
which pages to a true total and can return sample IDs in the same walk. labels.get is the
cheap way to count a whole label, but its counters are not exact either — see below.
labels.get counters are eventually consistent
labels.get returns messagesTotal/messagesUnread in one cheap call, which is why
list-unread-emails.mjs --stats reports every label that way. They are not exact: Gmail
recomputes them asynchronously, and they can be wrong by orders of magnitude before settling.
--stats reported Travel: 5179 total, 1313 unread. Three paging methods all said 13 —
labelIds: [Travel, UNREAD], label:Travel is:unread, and the same query with in:anywhere
(so Spam and Trash were not the gap). Minutes later labels.get self-corrected to
5197 total, 13 unread with nothing having touched the mailbox.
The tell is the same figure repeating across unrelated labels — that run showed exactly
501 unread on Events, Events/Entertainment and Meetup Events at once — and parent
totals that bear no relation to the sum of their children. Note this is the opposite
signature to resultSizeEstimate, which pins to a
ceiling and always under-reports; a stale label counter can err in either direction.
It is not label-resolution drift: buildLabelIndex() is an exact-name Map lookup over one
labels.list, so a name always resolves to the same id. The bad numbers come from Google.
Treat --stats as indicative. Confirm any count you are about to act on — especially one
sizing a bulk operation — with countMessagesMatching(), which pages and is exact.
Sender-matching helpers miss grouped from:(a OR b) queries
A /from:([^\s()]+)/ sweep requires a non-paren character right after from:, so on
from:(news.bizjournals.com OR engaged.bizjournals.com) it matches nothing — not the
first domain, not a partial. The config uses both spellings interchangeably
(config/categories.mjs has dozens of grouped ones), so a helper built
on that regex silently treats those domains as unclaimed.
coveredDomains() in audit-org-tag-coverage.mjs had this
bug and under-reported coverage until it switched to fromTokens(), which handles both
forms — import it from lib/gmail-query-tokens.mjs rather
than writing a third copy. (It briefly lived inside audit-label-drift.mjs, a CLI
entrypoint, so importing it dragged in that script's own config loading; moved out once a
second consumer needed it.) The hazard is now larger, not smaller: consolidated
categories (consolidate: true in config/categories.mjs) merge whole sender lists into
OR-grouped chunk filters, so a plain-regex helper reads an entire category as unclaimed.
The same helper class must also handle from:"Display Name" — a bare word with no dot
never suffix-matches a domain, so filters written that way stay invisible to any
domain-only comparison.
A verification check can assert a label the config never produces
list-unread-emails.mjs --verify reported AlphaSignal email has 'Product Updates' label: false for months. (That mode is now deleted for this reason; use
audit-label-drift.mjs --query "from:news@alphasignal.ai" --expect "Newsletters".) The mail was labeled correctly — the check was stale:
config/categories.mjs
routes AlphaSignal to Newsletters, and no live filter applies Product Updates to it. The
label it looked for was residue from an older config, still sitting on 321 messages.
A spot-check that hardcodes its expectation drifts from the config it is meant to verify,
and reads as a labeling failure when it is really a check failure. Derive the expected
label from the config, or audit with audit-label-drift.mjs,
which compares config, live filters and actual mail rather than trusting any one of them.
Sender domains do not reveal an organization's type
This is the canonical tally — other docs point here rather than repeating it, because a count duplicated across files goes stale in every copy at once.
Seven of seven type guesses made from domain names alone were wrong: fuegodance.com is a
shoe brand, tinyminotaur.com a tavern, zouk.us a festival organizer (all three first
proposed as PerformingGroup, which means the org performs), experiencehouse.co a
design cohort, thesisdriven.com a real-estate data business, houstonssc.com a
recreational sports league, and premierhw.com is Premier Health and Wellness — a
medical spa — not the "Premier Home Warranty" its entry was named for. Audit content
before assigning a type —
see audit-sender-signals.mjs, whose display-name output is
more reliable than its keyword scores (calibrated 2/5 on known answers; it did score
premierhw.com correctly at HealthAndBeautyBusiness:19).
premierhw.com adds a second trap: an abbreviation expanded from the domain. HW was
read as Home Warranty; the mail footer says Health and Wellness. An acronym in a domain is
a guess, not evidence — the body text is evidence.
Related: many domains are sending platforms, not organizations —
express.medallia.com carries 20 orgs including Airbnb, CVS and Marriott. Use
extract-platform-orgs.mjs, which enumerates the whole
domain; a 25-message sample of that domain found 3 of the 20.
Example Usage
Beyond single calendar operations, the server handles multi-step requests:
Please provide availability looking at both my personal and work calendar for this upcoming week.
I am looking for a good time to meet with someone in London for 1 hr.
Add this event to my calendar based on the attached screenshot.
Mark all unread emails from my boss as read.
More prompts, including attendance checks, coordinating against someone else's availability and the Gmail workflows, are in Advanced Usage.
Available Tools
Calendar Tools
| Tool | Description |
|---|---|
list-calendars |
List all available calendars |
list-events |
List events with date filtering |
search-events |
Search events by text query |
get-event |
Get a single event by ID |
create-event |
Create new calendar events |
update-event |
Update existing events |
delete-event |
Delete events |
get-freebusy |
Check availability across calendars, including external calendars |
get-current-time |
Current time in a requested or system timezone |
list-colors |
List available event colors |
Gmail Tools
| Tool | Description |
|---|---|
gmail-search-messages |
Search Gmail messages (e.g., is:unread, from:user@example.com) |
gmail-get-profile |
Get Gmail profile info (email, message count, thread count) |
gmail-modify-messages |
Modify messages: mark read/unread, archive, delete (permanent, bypasses Trash), add/remove labels |
gmail-create-label |
Create new labels for organizing emails (returns the existing label if the name is taken) |
gmail-create-filter |
Create filters to auto-organize emails (auto-label, archive, mark read, etc.) |
gmail-apply-filters |
Apply the account's existing filters to mail already in the mailbox (dryRun to preview) |
gmail-get-labels |
List every label, system and user-defined, with its id |
gmail-list-filters |
List every filter with its criteria and action |
gmail-get-message |
Fetch one message by id: headers by default, or full for the decoded body and attachment list |
gmail-delete-label |
Delete a user label by id (system labels are refused; no mail is deleted) |
gmail-delete-filter |
Delete a filter by id |
Documentation
The full index is docs/README.md: authentication, advanced usage, deployment, architecture, development, testing, the Gmail tools guide, the org-tag and defined-terms guides, schema.org email categorization, the backlog and the changelog.
Development
Code standards, script patterns and the full script and library inventory live in CLAUDE.md; contributor setup is in the Development Guide. npm run check-duplicates runs jscpd over src/.
Testing
npm test runs the unit suite; npm run test:integration needs live Google credentials. Suite size, credential setup and troubleshooting are in the Testing Guide.
Configuration
Every environment variable the server, the auth scripts and the root .mjs CLI read, with each default taken from the line of code that reads it. Other docs link here rather than keeping their own lists.
| Variable | Read by | Default | Purpose |
|---|---|---|---|
GOOGLE_OAUTH_CREDENTIALS |
src/auth/utils.ts, lib/gmail-client.mjs, auth-gmail.mjs |
gcp-oauth.keys.json in the project root |
Path to the OAuth client file; npx installs must set it |
GOOGLE_ACCOUNT_MODE |
src/auth/paths.js (server and calendar auth) |
test when NODE_ENV=test, else the .active-account file, else normal |
Account key for the calendar side |
ACCOUNT_MODE |
lib/gmail-client.mjs, auth-gmail.mjs, switch-account.mjs |
the .active-account file, else normal |
Account key for the Gmail CLI; the two sides read different variables |
CALENDARMCP_TOKEN_PATH |
src/auth/paths.js |
$XDG_CONFIG_HOME/google-calendar-mcp/tokens.json |
Calendar token file |
XDG_CONFIG_HOME |
src/auth/paths.js |
~/.config |
Base of the config directory holding both token files and .active-account |
GMAIL_AUTH_PORT |
auth-gmail.mjs |
3500 |
Redirect port for npm run auth:gmail; must be a registered redirect URI |
TRANSPORT |
src/config/TransportConfig.ts |
stdio |
stdio or http; --transport overrides |
HOST |
src/config/TransportConfig.ts |
127.0.0.1 |
Bind address for the HTTP transport; --host overrides |
PORT |
src/config/TransportConfig.ts |
3000 |
Port for the HTTP transport; --port overrides |
NODE_ENV |
src/server.ts, src/auth/paths.js, src/auth/tokenManager.ts |
unset | Only the value test matters: skips startup auth, selects the test account key, silences token log lines |
TEST_CALENDAR_ID |
src/tests/integration/ |
primary |
Calendar the integration tests write to; Doppler dev supplies it |
CLAUDE_API_KEY |
src/tests/integration/claude-mcp-integration.test.ts |
unset | Without it the Claude suite reports skipped |
Token Storage: see Token Storage in the Authentication Guide. The Claude Desktop config paths are under Installation.
Security
- OAuth tokens are stored securely in your system's config directory
- Credentials never leave your local machine
- All calendar operations require explicit user consent
Troubleshooting
- Build errors: run
npm install && npm run buildagain on a current Node LTS; if it still fails, deletebuild/and rebuild. - Anything authentication-related (credentials file not found, access blocked, token expired, redirect URI mismatch, the "Something went wrong" browser screen): see the Authentication Guide.
License
MIT
Support
- GitHub Issues (this fork)
- Upstream Discussions (nspady/google-calendar-mcp, the project this fork descends from)
- Documentation
