日历与邮件管理MCP服务

这是一款面向AI助手的模型上下文协议(MCP)服务器,将Google日历和Gmail集成到Claude等AI助手的工作流中,支持多日历事件管理、智能日程安排、邮件搜索与批量整理,适用于需要自动化处理日程和邮件的场景。

  • by integritystudio
  • TypeScript
  • 本地部署
  • 更新于
在 GitHub 查看 integritystudio/bot-army-google-calendar-mcp

使用说明

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 .mjs scripts that label, filter and archive a large mailbox from config; see Email Organization CLI

Version history is in the CHANGELOG.

Quick Start

Prerequisites

  1. A Google Cloud project with the Calendar API and the Gmail API enabled
  2. 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 build again on a current Node LTS; if it still fails, delete build/ 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