Google Calendar MCP
Provides tools for managing Google Calendar across multiple independent Google accounts through a single MCP interface, with per-account OAuth grants and role-based access control. Capabilities include listing calendars, reading events, querying free/busy information, and creating, updating, moving, and deleting events, as well as a built-in personal time-management/planning assistant.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Google Calendar MCP What's on my work calendar tomorrow?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
google-calendar-mcp
A multi-account, RBAC-enforced Google Calendar MCP server, with a personal time-management assistant built in.
One MCP interface → an account router → an authorization layer → N independent Google OAuth identities → the Google Calendar API.
Claude
│ (stdio, JSON-RPC)
┌───────▼────────┐
│ MCP Server │ 25 tools, filtered by session role
└───────┬────────┘
┌───────▼────────┐
│ Account Router │ explicit id, or refuse to guess
└───────┬────────┘
┌───────▼────────┐
│ Authorizer │ 4 gates, all must allow
└───────┬────────┘
┌──────────┬──────────┬──┴───────┬──────────┬──────────┐
│ personal │ work │ business │ family │ project │
│ OAuth #1 │ OAuth #2 │ OAuth #3 │ OAuth #4 │ OAuth #5 │
│ viewer │ editor │scheduler │ viewer │ editor │
└────┬─────┴────┬─────┴────┬─────┴────┬─────┴────┬─────┘
└──────────┴──────────┴──────────┴──────────┘
│
Google Calendar API v3Each account has its own OAuth grant, its own encrypted token file, its own role, and its own effective permissions. There is no shared session.
Contents
Related MCP server: Google Calendar MCP Server
Requirements
Node.js 20 or newer (tested on 22)
A Google Cloud project with the Calendar API enabled
One Google account per calendar you want to connect
Quick start
npm install
cp .env.example .env
# Generate the token-vault key and paste it into .env as MCP_MASTER_KEY
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
# Fill in GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (see Google Cloud setup)
npm run build
npm run auth -- --account personal --role viewer
npm run doctorGoogle Cloud setup
You need one Google Cloud project and one OAuth client. All five accounts authorize against that same client — that is the normal and supported pattern, and Google allows up to 100 refresh tokens per account per client.
1. Create the project
Name it, e.g.
calendar-mcp, and click Create.Make sure it is selected in the project picker at the top.
2. Enable the Calendar API
Go to APIs & Services → Library.
Search for Google Calendar API.
Click Enable.
3. Configure the OAuth consent screen
Go to APIs & Services → OAuth consent screen.
Choose a user type:
External — required for personal
@gmail.comaccounts.Internal — only if every account is in one Google Workspace organisation. This is simpler: no verification, no user cap.
Fill in the app name, your support email, and your developer email.
4. Add the scopes
Add exactly these, and no others:
Scope | Why |
| binds each token to an identity |
| records which Google account a token belongs to |
| list calendars, read events, query free/busy |
| create/update/move/delete events only |
Read-only accounts request only the first three.
Deliberately not requested:
.../auth/calendar. That scope also permits deleting whole calendars and rewriting sharing ACLs. This server never does either, so it never asks for the power to.
calendar.readonly and calendar.events are sensitive scopes. See the
publishing step below for what that means in practice.
5. Create the OAuth client
Go to APIs & Services → Credentials → Create Credentials → OAuth client ID.
Application type: Desktop app. ← this matters
Name it, then click Create.
Copy the client ID and client secret into
.env.
Why Desktop app: for installed apps Google accepts a loopback redirect (
http://127.0.0.1:<port>) on any port, so you do not have to register a fixed redirect URI and the auth flow keeps working if the port is busy. If you pick "Web application" instead, you must addhttp://127.0.0.1:42813/oauth2callbackas an authorized redirect URI and keepMCP_OAUTH_PORTpinned to that exact port.
6. Publish the app — do not skip this
Go to OAuth consent screen → Publishing status → Publish app → "In production".
This is the single most important step for a setup you intend to keep.
Publishing status | Refresh token lifetime |
Testing | expires after 7 days |
In production | does not expire (until revoked or unused for 6 months) |
If you leave the app in Testing, every account silently stops working a week later and you re-authorize all five. Google applies the 7-day cap to any Testing-status External app requesting more than basic profile scopes.
Publishing an unverified app with sensitive scopes has two consequences, both acceptable for personal use:
Each account sees a "Google hasn't verified this app" interstitial once, during consent. Click Advanced → Go to <app name> (unsafe). You are the developer of this app; you are the one it is warning you about.
The project is capped at 100 new users for its lifetime. You are using five.
You only need to submit for verification if you intend to distribute this to other people.
7. Add test users (only while in Testing)
If you keep the app in Testing while experimenting, add each of the five Google addresses under Audience → Test users. Accounts not on that list cannot authorize at all.
Connecting five accounts
Run the command once per account. Each run is an independent OAuth flow and produces an independent token file.
npm run auth -- --account personal --role viewer --access read_only
npm run auth -- --account work --role editor --access read_write
npm run auth -- --account business --role scheduler --access read_write
npm run auth -- --account family --role viewer --access read_only
npm run auth -- --account project --role editor --access read_writeEach run:
starts a loopback listener on 127.0.0.1,
opens Google's consent screen with
prompt=consent select_account,sign in as the account you named — the account chooser is shown every time precisely so the browser's current session is not silently reused,
verifies the returned
id_tokenand records the email,encrypts the tokens to
data/tokens/<id>.token.enc.json,registers the account in
data/accounts.json.
If you sign in as an account that is already connected under a different id, the command stops and tells you — two ids pointing at the same calendars is almost never intended.
Verify everything:
npm run doctorThis refreshes each token against Google, lists the visible calendars, and cross-checks that each account's primary calendar matches its registered email — which is a direct test that no two accounts have crossed tokens.
Useful follow-ups:
npm run accounts -- list
npm run accounts -- show work
npm run accounts -- calendars work
npm run accounts -- role personal viewer
npm run accounts -- set-timezone work Asia/Jakarta
npm run accounts -- disable family
npm run accounts -- remove project # revokes at Google, then deletesConnecting to Claude
Claude Desktop (local stdio) — the supported path
Claude Desktop launches local MCP servers as child processes over stdio. Edit:
Windows:
%APPDATA%\Claude\claude_desktop_config.jsonmacOS:
~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"google-calendar": {
"command": "node",
"args": ["C:\\Users\\you\\google-calendar-mcp\\dist\\index.js"],
"env": {
"GOOGLE_CLIENT_ID": "xxxx.apps.googleusercontent.com",
"GOOGLE_CLIENT_SECRET": "GOCSPX-xxxx",
"MCP_MASTER_KEY": "your-generated-key",
"MCP_DATA_DIR": "C:\\Users\\you\\google-calendar-mcp\\data",
"MCP_DEFAULT_TIMEZONE": "Asia/Jakarta",
"MCP_PRINCIPAL_ROLE": "admin"
}
}
}
}Use absolute paths, run npm run build first, and restart Claude Desktop
completely. Values in env win over .env, by design.
Claude Code
claude mcp add google-calendar -- node /absolute/path/to/dist/index.jsOr add the same JSON block to .mcp.json in your project.
Claude web / mobile (custom connectors) — what does not work
claude.ai connects to remote MCP servers over HTTP with OAuth. It cannot launch a process on your machine, so this server — a local stdio process reading an encrypted vault on your disk — is not reachable from the web or mobile apps as written.
Your options, honestly stated:
Option | Trade-off |
Use Claude Desktop or Claude Code | Works today. Recommended. |
Proxy via | Bridges stdio to HTTP, but you must expose and secure that endpoint yourself. |
Port to Streamable HTTP and host it | Real work: you would add MCP's OAuth authorization-server flow, per-user token isolation, and a trusted host. The RBAC layer here is transport-agnostic and would carry over unchanged; |
Do not expose this server over HTTP without adding authentication. As written it trusts its caller completely, which is correct for a local stdio process owned by one user and wrong for anything reachable over a network.
Planning assistant
Beyond reading and writing calendars, the server plans your week: it reads every connected calendar, fits a routine around the real commitments, and writes nothing until you approve.
Four workflows, as MCP prompts
Prompt | What it does |
| Review the week just gone, propose the week ahead, apply on approval |
| One day, planned around what is already booked |
| Short morning briefing: what is fixed, what clashes, best focus block |
| Find realistic slots for a specific commitment |
These are prompts rather than tools because a weekly planning session is something you start, not something the model decides to do. They carry the operating rules, so the behaviour survives a fresh conversation.
The routine model
data/routine.json describes the week in three kinds of commitment:
anchor - a fixed window that structures the day (the main job). May be porous: a work-from-anywhere block reserves 10:00-19:00 but commits only 7h of it, leaving genuine gaps that lunch, dinner and English practice reuse. That is what stops the planner blanking out nine hours it has no business blanking out.
fixture - short daily invariants: morning routine, meals, wind-down.
flexible - a duration that must land somewhere sensible. The planner chooses when, by score.
Each activity declares an energy kind, and that drives placement against a focus curve anchored to your wake time:
Energy | Placed where | Example |
| 1-2.5h after waking | jogging |
| peak analytical window, 1.5-4h after waking | DSA study |
| anywhere with decent energy | part-time work, English |
| position matters, focus does not | meals, wind-down |
Rules the engine enforces
These are code, not prompt text, so they hold every time:
Sleep is carved out first; the day is what remains.
Real calendar events outrank everything in the routine.
Exercise stays in the morning.
DSA lands at peak focus before work, never straight after a long block.
Nothing is placed within 90 minutes after a draining activity.
Part-time work is never placed inside the main job window.
Meals and English may borrow porous WFA gaps, capped at the anchor's spare capacity.
A break inside the work block counts as a real break.
Recurring activities keep the same clock time all week. A predictable routine beats a marginally better one that moves.
Propose, approve, apply
propose_schedule reads calendars, computes a plan, writes nothing -> plan_id
Claude shows you the week and the workload verdict
apply_schedule returns a confirm_token and an exact summary
you approve
apply_schedule with the token -> events createdThe gate is structural, not advisory:
propose_scheduleholds no write permission, so it cannot write.Plans expire after an hour; the calendars will have moved on.
A plan applies once.
Busy time is re-read immediately before writing, so a meeting booked between proposal and approval makes that block skip, not double-book.
The nine-hour main job block is never written. It describes an obligation you already have, and writing it would bury the calendar it is meant to clarify.
It tells you when the week does not fit
A 9-hour main job plus 3 hours of part-time plus study and exercise comes to roughly 15h45m of scheduled time per weekday. The planner produces the best arrangement it can and then says so plainly:
verdict: heavy
scheduled: 85.5h productive: 74.3h average sleep: 7h
[warning] Monday is scheduled for 15h 45m, over your 12h ceiling.
[warning] Monday has 5h of demanding work with no break in it.Sessions that could not be placed are reported as shortfalls, never dropped quietly. A sleep breach is an error, not a warning: everything else in the plan is paid for out of sleep.
A typical weekday it produces:
05:45 wake
05:45 Morning routine
06:30 Jogging / exercise
07:30 Breakfast
08:15 DSA / competitive programming <- peak focus, before work
10:00 Main job (WFA)
12:30 Lunch <- inside the WFA gap
15:15 English speaking practice <- inside the WFA gap
18:15 Dinner <- inside the WFA gap
19:15 Part-time remote work
22:15 Wind down
22:45 sleepTuning it
In conversation: "make DSA 60 minutes", "I want to wake at 6", "drop
English to twice a week" - these map to update_routine and persist.
reset_routine restores the default. data/routine.json is plain JSON and
safe to edit by hand; invalid values are rejected at startup with the offending
activity named.
Roles and permissions
Permission vocabulary
calendar.read calendar.create account.list permission.read
calendar.search calendar.update account.connect permission.manage
calendar.freebusy calendar.reschedule account.disconnect system.configure
calendar.delete account.configureRole → permissions
Permission | viewer | editor | scheduler | admin |
| ✅ | ✅ | ✅ | ✅ |
| ✅ | ✅ | ✅ | ✅ |
| ✅ | ✅ | ✅ | ✅ |
| ❌ | ✅ | ✅ | ✅ |
| ❌ | ✅ | ✅ | ✅ |
| ❌ | ✅ | ✅ | ✅ |
| ❌ | ❌ | ❌ | ✅ |
| ✅ | ✅ | ✅ | ✅ |
| ❌ | ❌ | ❌ | ✅ |
| ❌ | ❌ | ❌ | ✅ |
| ❌ | ❌ | ❌ | ✅ |
| ✅ | ✅ | ✅ | ✅ |
| ✅ | ✅ | ✅ | ✅ |
| ❌ | ❌ | ✅ | ✅ |
| ✅ | ✅ | ✅ | ✅ |
| ❌ | ❌ | ❌ | ✅ |
| ❌ | ❌ | ❌ | ✅ |
admin holds the * wildcard. Deletion is admin-only, which is what makes
"work = editor" correctly resolve to delete = denied.
editor and scheduler hold identical calendar permissions. They differ in
the routine: scheduler can tune it, editor cannot, because shaping the week
is scheduling work rather than event editing.
Planning is read-only, so even a viewer session can ask "what would a good
week look like?" - applying that plan needs calendar.create on a real account.
Role → accounts → actions
With the recommended layout:
Account | Role | read | search | free/busy | create | update | delete |
personal | viewer | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
work | editor | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
business | scheduler | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
family | viewer | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
project | editor | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
The deployed account layout
Account id | Purpose | Role | Can create/update | Can delete |
| Primary personal calendar |
| yes | yes |
| Secondary personal calendar |
| no | no |
| Full-time job |
| yes | no |
| Part-time job |
| yes | no |
An account id is a slot, and each slot binds to exactly one Google address; connecting the same address under a second id is refused.
Role beats configuration.
personal2holds read/write OAuth scopes but theviewerrole, and its effective permission is read-only. Widening the OAuth grant, or editingaccounts.json, cannot grant a capability the role does not carry. Tool hiding is a convenience; the server-side check is the source of truth.
Two levels of role
MCP_PRINCIPAL_ROLE— the role the session runs as. A ceiling over everything. Set it toviewerand the whole server is read-only regardless of account roles.Each account's
role— a ceiling for that account.
An action is permitted only if both allow it. Setting the session to viewer
is the simplest way to run a strictly read-only setup.
Security model
Four gates, all must allow
Every privileged call passes through Authorizer.check. There is no second path.
Session role — does
MCP_PRINCIPAL_ROLEgrant this permission at all?Account role — does this account's role grant it?
Account overrides —
deniedPermissionswins over everything, including admin and the wildcard.Granted OAuth scopes — did Google actually grant write access?
Gate 4 is what makes the model hold under a tampered config. Gates 1–3 read from
local files a user could edit. If someone promotes a read-only account to
admin in accounts.json, writes still fail: the stored token physically
cannot write, and the server checks that before calling Google.
Token handling
Encryption at rest. AES-256-GCM, key derived per file with scrypt (N=2¹⁵) from
MCP_MASTER_KEY. Every file has its own random salt and IV.One file per account.
data/tokens/<id>.token.enc.json. No shared token file that a bug could cross-wire.Bound identity. Each encrypted record embeds its account id. Reading
personal's file and findingworkinside is a hard failure, not a warning. This is tested directly.Atomic, serialised writes. Write temp → fsync → rename, with a per-account promise chain. Concurrent refreshes cannot interleave and truncate.
Never exposed. No tool returns a token, a refresh token or the client secret. Integration tests assert this over the actual tool output.
Redacted logs. The logger strips secrets by key name and by value shape (
ya29.…,1//…,GOCSPX-…, JWTs), so a token cannot reach a log file even under an innocuous key.stderr only. stdout belongs to the JSON-RPC stream;
console.logis reassigned to stderr at startup.
Account isolation
One
OAuth2Clientper account id, never shared.A client is loaded only from its own vault file.
The refresh listener captures the account id in its closure, so a refreshed token is always written back to the file it came from.
npm run doctorcross-checks each account's primary calendar id against its registered email — a live test that no two accounts are crossed.
Input validation
Every tool schema is strict: an unknown key is rejected rather than
silently dropped. This matters more than it sounds. A loose schema would
discard a misspelled acount: "work", leaving account undefined, and the
request would fall through to account inference — potentially writing to a
different calendar than the one named. Strict schemas turn that into an
immediate, visible error.
Concurrency
Token vault writes are serialised per account and atomic (write, fsync, rename), so simultaneous refreshes cannot interleave or truncate.
A schedule plan is claimed synchronously before the first
await, so two concurrentapply_schedulecalls cannot both reach the write loop. The loser is told the plan is already being applied.A failed apply releases its claim, leaving the plan usable for a retry.
apply_schedulerefuses to write at all if any calendar could not be re-read first: booking over events the server cannot see is worse than not booking.
Destructive actions
delete_event and disconnect_account require two calls.
The first returns CONFIRMATION_REQUIRED with a plain-language description of
exactly what would happen and a confirm_token. The token is bound to a SHA-256
fingerprint of action + account + calendar + event, is single-use, and expires
(default 5 minutes).
A token minted for "delete X from business" is cryptographically useless against "delete X from personal" — and a mismatch burns the token rather than just rejecting it.
Why at the server layer: MCP's destructiveHint annotation is advisory, and
elicitation is optional in the protocol. Safety that depends on the client
implementing a prompt is not safety. The server enforces its own gate, and the
annotations are sent as well so good clients can also prompt.
Threat model — what this does not defend against
Being explicit about the boundary:
A local attacker with your
MCP_MASTER_KEYand the data directory can decrypt the vault. The key lives in your environment or.env; encryption protects the files at rest (backups, sync folders, stolen disks), not against code running as you.A malicious MCP client. The server trusts its stdio peer. That is correct for a local process you launched and wrong for a network service.
Prompt injection reaching Claude. An event description saying "delete everything" cannot escalate privileges — RBAC and confirmation are enforced server-side — but Claude may still relay hostile text to you. The permission model bounds the blast radius; it does not make event content trustworthy.
Tools
25 tools and 4 prompts. Which tools are advertised depends on
MCP_PRINCIPAL_ROLE; all are enforced server-side regardless.
Read (viewer and above)
Tool | Purpose |
| every account with role, state and effective permissions |
| one account in detail, including granted OAuth access |
| the role model, and why something was denied |
| calendars across accounts, with Google access roles |
| events in a range; fans out across accounts by default |
| unified cross-account timeline, sorted |
| full-text search across accounts |
| one event (account required — ids are account-scoped) |
| busy periods plus a merged timeline |
| overlapping events across accounts |
| free slots across every checked calendar |
Write (editor / scheduler and above)
Tool | Notes |
| routes to one account; |
| patch semantics; time changes also need |
| between calendars within one account (Google cannot move across accounts) |
Planning
Tool | Notes |
| Sleep, work hours, every recurring activity |
| Computes a plan; writes nothing; returns a |
| Creates the events, two-phase confirmed |
| Hours per category, busiest/lightest day, long stretches |
| Change sleep, limits or one activity (scheduler/admin) |
| Restore the defaults (scheduler/admin) |
Admin
Tool | Notes |
| destructive; two-phase confirmation |
| returns the command for the user to run; consent happens in their browser |
| destructive; revokes at Google then deletes locally |
| warns if the role exceeds the granted OAuth scopes |
| pause an account without deleting its tokens |
Deliberately not implemented
OAuth-in-a-tool. A browser consent flow cannot be driven from inside a stdio tool call.
connect_accountreturns the exact command instead.delete_calendar/ ACL editing. Out of scope, and the reason the broadcalendarscope is never requested.Cross-account move. Google has no such operation. Create on the target and delete the original — two explicitly authorized steps.
Example prompts
"Show me all my calendars."
list_calendars→ every calendar on all five accounts, labelled by account.
"What do I have tomorrow?"
list_all_events→ one timeline across all five, each event tagged with its account and email.
"Find conflicts across my work and personal calendars."
find_cross_account_conflictswithaccounts: ["work","personal"]→Doctor Appointment 09:00-10:00 personal Team Meeting 09:30-10:30 work → 30 minutes of overlap
"Find a 60-minute slot next Tuesday when I'm free across all my calendars."
find_available_slots→ merges busy periods from all five, returns slots, and reportschecked_accountsso you can see nothing was missed.
"Create this event on my work calendar."
create_eventwithaccount: "work"→ created; the response saysaccount_resolution: "explicit".
"Create a meeting tomorrow at 10am." (no calendar named)
create_eventwith no account →AMBIGUOUS_ACCOUNT, listing["business","project","work"]. Claude asks which one. Nothing is written.
"Delete the event from my business calendar." First call →
CONFIRMATION_REQUIRED:delete "Board meeting" (Mon, 21 Sep 2026, 09:00) from calendar "Primary" on account "business" (business@example.com)Claude shows that, you approve, second call deletes it.
RBAC in action
"Add a dentist appointment to my personal calendar."
{ "error": { "code": "PERMISSION_DENIED", "message": "Permission \"calendar.create\" is denied on account \"personal\": account \"personal\" has role \"viewer\", which does not grant \"calendar.create\"" } }Claude explains the limit instead of retrying, and instead of writing elsewhere.
"Delete that meeting from work." Denied:
workiseditor, and deletion is admin-only.
"Why can't you delete that?"
describe_permissions→ the full role table and this session's role.
Testing
npm test # 195 tests
npm run test:watch
npm run typecheck
npm run doctor # live check against GoogleSuite | Covers |
| role expansion, the four gates, the account matrix |
| per-account vaults, crossed-token detection, traversal, concurrency |
| ambiguity refusal, explicit routing, no fallback |
| token binding, replay across accounts, expiry, single use |
| interval algebra, conflicts, slots, DST, |
| client binding, refresh persistence, crypto, redaction |
| visibility per role, annotation correctness |
| placement rules, consistency, WFA gaps, overload honesty |
| real handlers end-to-end with a fake Google layer |
| propose/approve/apply gate, re-check before writing |
Expected output:
Test Files 10 passed (10)
Tests 195 passed (195)Representative assertions:
viewer cannot create / update / delete →
PERMISSION_DENIEDeditor can create and update, cannot delete
scheduler can schedule; admin can manage accounts
personaltoken →personalvault only; a swapped file is rejecteda confirm token from
businesscannot delete onworkan unqualified write with 3 eligible accounts →
AMBIGUOUS_ACCOUNT, nothing written
The integration tests run the real authorizer, router and confirmation guard. Only Google's HTTP layer is replaced, so an RBAC regression fails there too.
Troubleshooting
invalid_grant / tokens die every 7 days
Your OAuth consent screen is still in Testing. Set publishing status to In production (see step 6), then re-authorize each account. This is the most common failure with this kind of setup.
"Google hasn't verified this app"
Expected for an unverified app using sensitive scopes. Advanced → Go to <app> (unsafe). To remove it entirely you would need Google verification.
Access blocked: <app> has not completed the Google verification process
The account is not on the test-user list while the app is in Testing. Either add it under Audience → Test users, or publish to production.
Google did not return a refresh token
The account previously authorized this client. Remove the app at https://myaccount.google.com/permissions for that account, then re-run.
VAULT_LOCKED / "Could not decrypt the token vault"
MCP_MASTER_KEY differs from the value used at authorization time. Restore the
original key, or delete data/tokens/ and re-authorize.
Claude Desktop shows no tools
npm run build— the config points atdist/, notsrc/.Use absolute paths in
claude_desktop_config.json.Restart Claude Desktop fully.
Check
MCP_PRINCIPAL_ROLE:viewerlegitimately hides every write tool.Run the server manually — it prints startup errors to stderr:
node dist/index.jsClaude Desktop logs:
%APPDATA%\Claude\logs\(Windows),~/Library/Logs/Claude/(macOS).
The server starts then immediately disconnects
Something wrote to stdout and corrupted the JSON-RPC stream. This server
reassigns console.log to stderr at startup; if you add code, never print to
stdout.
AMBIGUOUS_ACCOUNT on every write
Working as designed: several accounts can take the write. Name one
(account: "work"), or narrow the roles so only one account is eligible.
PERMISSION_DENIED when the role looks right
Run npm run doctor. The usual cause is a role/scope mismatch — the account was
authorized read_only but assigned a writing role. Fix:
npm run auth -- --account work --role editor --access read_writeCALENDAR_NOT_FOUND for a calendar you can see
The account's includeCalendars / excludeCalendars policy hides it. Check
npm run accounts -- show <id>.
Rate limits
GOOGLE_RATE_LIMITED is retried with exponential backoff and jitter. Persistent
limits mean too many calendars or too wide a range — narrow the request.
Configuration reference
Variable | Required | Default | Meaning |
| ✅ | — | OAuth client id |
| ✅ | — | OAuth client secret |
| ✅ | — | token-vault encryption passphrase |
|
| session role ceiling | |
| host zone | IANA zone, e.g. | |
|
| registry and vault location | |
|
| loopback port for | |
|
| hide tools the session role cannot use | |
|
| confirm-token lifetime | |
|
| how long a proposed schedule stays applicable | |
|
|
|
Data layout
data/
├── accounts.json # roles, labels, calendar policy — no secrets
├── routine.json # sleep, work hours, activities — no secrets
└── tokens/
├── personal.token.enc.json # AES-256-GCM, unique salt + IV
├── work.token.enc.json
├── business.token.enc.json
├── family.token.enc.json
└── project.token.enc.jsonBack up data/ and MCP_MASTER_KEY together — neither is useful alone.
data/ is gitignored.
Per-account options in accounts.json
{
"id": "work",
"role": "editor",
"enabled": true,
"defaultCalendarId": "primary",
"timezone": "Asia/Jakarta", // overrides the server default
"includeCalendars": ["primary"], // allow-list
"excludeCalendars": ["holidays@..."], // always wins
"deniedPermissions": ["calendar.delete"], // beats any role, including admin
"extraPermissions": [] // widen without changing the role
}Project layout
src/
├── index.ts stdio entry; stdout protection
├── server.ts tool registration and role filtering
├── context.ts dependency wiring
├── config.ts environment configuration
├── errors.ts typed, secret-free error model
├── confirm.ts two-phase destructive-action guard
├── rbac/
│ ├── permissions.ts the vocabulary
│ ├── roles.ts role → permissions
│ └── authorization.ts the four gates ← Google-agnostic
├── auth/
│ ├── crypto.ts AES-256-GCM + scrypt
│ ├── token-store.ts per-account encrypted vault
│ ├── oauth.ts PKCE loopback flow
│ ├── scopes.ts minimum-scope selection
│ └── account-manager.ts per-account client binding and refresh
├── accounts/
│ ├── registry.ts accounts.json
│ └── routing.ts explicit routing; refuses to guess
├── google/
│ ├── calendar-client.ts error translation and retry
│ └── calendar-service.ts calendar operations, per account
├── scheduling/
│ ├── intervals.ts merge, invert, conflict sweep
│ └── slots.ts availability search
├── planner/
│ ├── routine.ts the routine profile and its store
│ ├── energy.ts focus curve, anchored to wake time
│ ├── planner.ts the scheduling engine (deterministic)
│ ├── workload.ts overload detection, honest verdicts
│ └── plan-store.ts proposals held between propose and apply
├── prompts.ts plan_week, plan_tomorrow, daily_check_in, find_time_for
├── tools/ MCP tool definitions
├── util/ logger, time, env
└── cli/ auth, accounts, doctorrbac/ imports nothing from google/. The authorization layer is reusable for
any resource, and is tested without a network.
Sources
Using OAuth 2.0 to access Google APIs — refresh-token expiry and per-client limits
Unverified apps · When verification is not needed · Sensitive scope verification
Prior art reviewed: nspady/google-calendar-mcp — multi-account, but auto-selects the highest-permission account for writes and has no role model. This server refuses to guess instead.
Licence
MIT
Available Tools
25 toolsapply_scheduleApply an approved scheduleA
Create calendar events for a plan the user has approved. Requires a plan_id from propose_schedule. The first call returns a confirm_token and a summary of exactly what will be created; call again with the token only after the user has said yes. Busy time is re-checked immediately before writing, so anything that became busy since the proposal is skipped rather than double-booked.
Inputs: plan_id (required), account, calendar, confirm_token, +1 more.
check_freebusyCheck free/busyARead-onlyIdempotent
Return the busy periods across accounts in a time range, plus the merged busy timeline. Use this to answer "am I free at 3pm?" without reading event details.
Inputs: time_max (required), time_min (required), accounts, timezone, +1 more.
connect_accountConnect a Google accountA
Return the exact command the user must run to connect a new Google account. OAuth consent happens in the user's browser, not through this tool, so this returns instructions rather than performing the login.
Inputs: account (required), role, access.
create_eventCreate an eventA
Create an event on one account. If account is omitted and more than one account could take the write, this fails with AMBIGUOUS_ACCOUNT - ask the user which calendar to use rather than picking one.
Inputs: end (required), start (required), title (required), account, +9 more.
delete_eventDelete an eventADestructive
Permanently delete an event. Destructive and irreversible. The first call returns CONFIRMATION_REQUIRED with a description of exactly what would be deleted and a confirm_token; show that to the user, and only call again with the token once they have explicitly approved it.
Inputs: account (required), event_id (required), calendar, send_updates, +1 more.
describe_permissionsExplain roles and permissionsARead-onlyIdempotent
Explain the role model: which permissions each role grants, what role this session runs as, and what each connected account may do. Use this to answer "why can't you delete that?" accurately instead of guessing.
Inputs: no named parameters.
disconnect_accountDisconnect a Google accountADestructive
Revoke this server's access to a Google account, delete its stored tokens and remove it from the registry. Destructive and irreversible: reconnecting requires the user to complete Google consent again. Requires confirmation.
Inputs: account (required), confirm_token.
find_available_slotsFind available time slotsARead-onlyIdempotent
Find times when the user is free across all their calendars at once. Merges busy periods from every checked account, then returns slots of the requested length. Always report which accounts were checked, since a slot is only trustworthy if every relevant calendar was included.
Inputs: time_max (required), time_min (required), duration_minutes (required), accounts, +7 more.
find_cross_account_conflictsFind cross-account conflictsARead-onlyIdempotent
Find events that overlap across different accounts - for example a doctor appointment on personal that collides with a meeting on work. Returns each conflicting pair with the exact overlap window.
Inputs: time_max (required), time_min (required), accounts, timezone, +1 more.
get_accountGet account detailsARead-onlyIdempotent
Show one account in detail: its role, the OAuth access level actually granted by Google, its calendars policy and its effective permissions.
Inputs: account (required).
get_eventGet one eventARead-onlyIdempotent
Fetch a single event by id. The account must be given explicitly, because an event id is only meaningful within the account it came from.
Inputs: account (required), event_id (required), calendar, timezone.
get_routineShow the routine profileARead-onlyIdempotent
Show the routine that drives scheduling: sleep and wake times, the main job window, and every recurring activity with its duration, preferred window and energy level. Read this before proposing a schedule so you can explain what is fixed and what is flexible.
Inputs: no named parameters.
list_accountsList connected accountsARead-onlyIdempotent
List every connected Google account with its id, email, role, enabled state and what it is permitted to do. Call this first when the user refers to a calendar by name ("my work calendar") so you can map it to an account id.
Inputs: no named parameters.
list_all_eventsUnified cross-account event listARead-onlyIdempotent
Unified view of every event across every readable account and calendar in a time range, sorted by start time. Use this when the user asks about their whole schedule, or before reasoning about cross-account conflicts.
Inputs: time_max (required), time_min (required), timezone.
list_calendarsList calendarsARead-onlyIdempotent
List the calendars available on one or more connected accounts. Returns the calendar id needed by the event tools, along with the access level the account has on each calendar.
Inputs: accounts.
list_eventsList eventsARead-onlyIdempotent
List events in a time range. With no accounts argument this queries every account you may read and every visible calendar on them, which is what you want for questions like "what do I have tomorrow?". Each event is labelled with the account it came from.
Inputs: time_max (required), time_min (required), accounts, calendar, +2 more.
move_eventMove an event between calendarsA
Move an event to a different calendar on the SAME account. Google cannot move an event between two different Google accounts; to do that, create it on the target account and delete the original.
Inputs: account (required), event_id (required), to_calendar (required), from_calendar (required).
propose_schedulePropose a scheduleARead-onlyIdempotent
Read every connected calendar, find the real commitments, and compute a balanced schedule around them. Writes nothing — it returns a plan with a plan_id, an explanation of why each block landed where it did, and an honest workload verdict. Show the plan to the user and get explicit approval before calling apply_schedule.
Inputs: days, only, exclude, accounts, +2 more.
reset_routineReset the routine to defaultsB
Restore the built-in default routine: early wake, morning exercise, a 10:00–19:00 work-from-anywhere main job with flexible gaps, morning DSA study, midday English practice, and evening part-time work. Overwrites any customisation.
Inputs: timezone.
review_weekReview a weekARead-onlyIdempotent
Analyse a week that already exists on the calendars: hours per category, how balanced the days are, where the long unbroken stretches are, and whether sleep is being protected. Use this to open a weekly planning session, before proposing the next week.
Inputs: week_of, accounts, timezone.
search_eventsSearch eventsARead-onlyIdempotent
Full-text search across events in a time range. Matches titles, descriptions, locations and attendees. Searches every readable account unless accounts is given.
Inputs: query (required), time_max (required), time_min (required), accounts, +2 more.
set_account_enabledEnable or disable an accountA
Enable or disable an account without deleting its tokens. A disabled account is excluded from every operation, including read fan-outs.
Inputs: account (required), enabled (required).
set_account_roleChange an account roleA
Change the role assigned to a connected account. Note that a role can never exceed the OAuth scopes Google actually granted: promoting a read-only account to editor will not let it write.
Inputs: role (required), account (required).
update_eventUpdate an eventA
Change fields on an existing event. Only the fields you supply are modified; everything else is left alone. The account must be explicit because event ids are account-scoped.
Inputs: account (required), event_id (required), end, start, +7 more.
update_routineAdjust the routineA
Change sleep times, daily limits, or one activity in the routine. Use this when the user says things like "make DSA 60 minutes", "I want to wake at 6", or "move part-time work earlier". Changes persist to data/routine.json.
Inputs: sleep, activity, max_scheduled_hours_per_day.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 25 tools
Most tools target a distinct resource+action pair (event CRUD, account admin, free/busy, scheduling). The one real overlap is list_events vs list_all_events: list_events with no `accounts` argument already queries every readable account and calendar, which makes it functionally near-identical to list_all_events, so an agent may hesitate between them. search_events and the free/busy trio are sufficiently differentiated.
Consistent snake_case verb_noun pattern throughout: get_/list_/create_/update_/delete_/set_ prefixes applied uniformly (get_account, list_events, create_event, set_account_role, find_available_slots, propose_schedule). No mixed conventions or vague verbs like 'process' or 'run'.
25 tools sits at the heavy end of the range for a single-domain calendar server. The breadth (multi-account admin, routine management, free/busy analytics, plan propose/apply) justifies many of them, but the surface is larger than needed and some clustering (three free/busy tools, two list/all-event tools) could be consolidated.
Covers event lifecycle (create/update/move/delete/get/list/search), account lifecycle (connect/disconnect/roles/enabled), calendars, availability reasoning and scheduling plans. Minor gaps: no attendee/invite handling, no recurring-event management, and no calendar creation/deletion. Core workflows are otherwise complete with proper confirmation flows for destructive ops.
Related MCP Connectors
Calendar API for AI agents: events, availability, Google/Microsoft setup, scheduling, and iCal.
Scheduling infrastructure for AI agents across Google and Microsoft calendars.
Gmail, Outlook, Drive, OneDrive and calendars for AI agents. Many accounts, one endpoint, audit log.
Every email and calendar account you connect, for AI agents, with permissions and approvals.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables AI assistants to interact with Google Calendar through OAuth 2.1 authentication, supporting full calendar and event management including creation, updates, deletion, and search across multiple calendars.1MIT
- AlicenseNot gradedqualityDmaintenanceEnables management of multiple Google Calendar accounts with support for searching, creating, updating, and deleting events on primary calendars through natural language, with encrypted credential storage.14 npmMIT
- AlicenseNot gradedqualityDmaintenanceEnables AI assistants to manage calendars and tasks through natural language, supporting Google Calendar operations like event creation, availability checking, and smart scheduling. It features schedule analysis, task reminders, and meeting time recommendations to streamline productivity.MIT
- AlicenseNot gradedqualityCmaintenanceEnables AI assistants to view, create, update, search, and manage Google Calendar events, including multi-account support and availability checks.16 npmBusiness Source 1.1