Google Calendar MCP
# 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 v3
```
Each 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
1. [Requirements](#requirements)
2. [Quick start](#quick-start)
3. [Google Cloud setup](#google-cloud-setup)
4. [Connecting five accounts](#connecting-five-accounts)
5. [Connecting to Claude](#connecting-to-claude)
6. [Planning assistant](#planning-assistant)
7. [Roles and permissions](#roles-and-permissions)
8. [Security model](#security-model)
9. [Tools](#tools)
10. [Example prompts](#example-prompts)
11. [Testing](#testing)
12. [Troubleshooting](#troubleshooting)
13. [Configuration reference](#configuration-reference)
---
## 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
```bash
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 doctor
```
---
## Google 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
1. Open <https://console.cloud.google.com/projectcreate>.
2. Name it, e.g. `calendar-mcp`, and click **Create**.
3. Make sure it is selected in the project picker at the top.
### 2. Enable the Calendar API
1. Go to **APIs & Services → Library**.
2. Search for **Google Calendar API**.
3. Click **Enable**.
### 3. Configure the OAuth consent screen
1. Go to **APIs & Services → OAuth consent screen**.
2. Choose a user type:
- **External** — required for personal `@gmail.com` accounts.
- **Internal** — only if every account is in one Google Workspace
organisation. This is simpler: no verification, no user cap.
3. Fill in the app name, your support email, and your developer email.
### 4. Add the scopes
Add exactly these, and no others:
| Scope | Why |
|---|---|
| `openid` | binds each token to an identity |
| `.../auth/userinfo.email` | records which Google account a token belongs to |
| `.../auth/calendar.readonly` | list calendars, read events, query free/busy |
| `.../auth/calendar.events` | 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
1. Go to **APIs & Services → Credentials → Create Credentials → OAuth client ID**.
2. Application type: **Desktop app**. ← this matters
3. Name it, then click **Create**.
4. 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 add
> `http://127.0.0.1:42813/oauth2callback` as an authorized redirect URI and keep
> `MCP_OAUTH_PORT` pinned 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.
```bash
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_write
```
Each run:
1. starts a loopback listener on 127.0.0.1,
2. opens Google's consent screen with `prompt=consent select_account`,
3. **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,
4. verifies the returned `id_token` and records the email,
5. encrypts the tokens to `data/tokens/<id>.token.enc.json`,
6. 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:
```bash
npm run doctor
```
This 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:
```bash
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 deletes
```
---
## Connecting 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.json`
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
```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
```bash
claude mcp add google-calendar -- node /absolute/path/to/dist/index.js
```
Or 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 `mcp-remote` | 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; `src/index.ts` is the only file that assumes stdio. |
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 |
|---|---|
| `plan_week` | Review the week just gone, propose the week ahead, apply on approval |
| `plan_tomorrow` | One day, planned around what is already booked |
| `daily_check_in` | Short morning briefing: what is fixed, what clashes, best focus block |
| `find_time_for` | 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 |
|---|---|---|
| `physical` | 1-2.5h after waking | jogging |
| `high` | peak analytical window, 1.5-4h after waking | DSA study |
| `medium` | anywhere with decent energy | part-time work, English |
| `low` | 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 created
```
The gate is structural, not advisory:
- `propose_schedule` holds 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 sleep
```
### Tuning 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.configure
```
### Role → permissions
| Permission | viewer | editor | scheduler | admin |
|---|:--:|:--:|:--:|:--:|
| `calendar.read` | ✅ | ✅ | ✅ | ✅ |
| `calendar.search` | ✅ | ✅ | ✅ | ✅ |
| `calendar.freebusy` | ✅ | ✅ | ✅ | ✅ |
| `calendar.create` | ❌ | ✅ | ✅ | ✅ |
| `calendar.update` | ❌ | ✅ | ✅ | ✅ |
| `calendar.reschedule` | ❌ | ✅ | ✅ | ✅ |
| `calendar.delete` | ❌ | ❌ | ❌ | ✅ |
| `account.list` | ✅ | ✅ | ✅ | ✅ |
| `account.connect` | ❌ | ❌ | ❌ | ✅ |
| `account.disconnect` | ❌ | ❌ | ❌ | ✅ |
| `account.configure` | ❌ | ❌ | ❌ | ✅ |
| `routine.read` | ✅ | ✅ | ✅ | ✅ |
| `plan.propose` | ✅ | ✅ | ✅ | ✅ |
| `routine.manage` | ❌ | ❌ | ✅ | ✅ |
| `permission.read` | ✅ | ✅ | ✅ | ✅ |
| `permission.manage` | ❌ | ❌ | ❌ | ✅ |
| `system.configure` | ❌ | ❌ | ❌ | ✅ |
`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 |
|---|---|---|:--:|:--:|
| `personal` | Primary personal calendar | `admin` | yes | yes |
| `personal2` | Secondary personal calendar | `viewer` | no | no |
| `work1` | Full-time job | `editor` | yes | no |
| `work` | Part-time job | `editor` | 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.** `personal2` holds read/write OAuth scopes but
> the `viewer` role, and its effective permission is read-only. Widening the
> OAuth grant, or editing `accounts.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 to `viewer` and 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.
1. **Session role** — does `MCP_PRINCIPAL_ROLE` grant this permission at all?
2. **Account role** — does this account's role grant it?
3. **Account overrides** — `deniedPermissions` wins over everything, including
admin and the wildcard.
4. **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 finding `work` inside 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.log` is
reassigned to stderr at startup.
### Account isolation
- One `OAuth2Client` per 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 doctor` cross-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
concurrent `apply_schedule` calls 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_schedule` refuses 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_KEY` and 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 |
|---|---|
| `list_accounts` | every account with role, state and effective permissions |
| `get_account` | one account in detail, including granted OAuth access |
| `describe_permissions` | the role model, and why something was denied |
| `list_calendars` | calendars across accounts, with Google access roles |
| `list_events` | events in a range; fans out across accounts by default |
| `list_all_events` | unified cross-account timeline, sorted |
| `search_events` | full-text search across accounts |
| `get_event` | one event (account required — ids are account-scoped) |
| `check_freebusy` | busy periods plus a merged timeline |
| `find_cross_account_conflicts` | overlapping events across accounts |
| `find_available_slots` | free slots across every checked calendar |
### Write (editor / scheduler and above)
| Tool | Notes |
|---|---|
| `create_event` | routes to one account; `AMBIGUOUS_ACCOUNT` rather than guessing |
| `update_event` | patch semantics; time changes also need `calendar.reschedule` |
| `move_event` | between calendars **within one account** (Google cannot move across accounts) |
### Planning
| Tool | Notes |
|---|---|
| `get_routine` | Sleep, work hours, every recurring activity |
| `propose_schedule` | Computes a plan; writes nothing; returns a `plan_id` |
| `apply_schedule` | Creates the events, two-phase confirmed |
| `review_week` | Hours per category, busiest/lightest day, long stretches |
| `update_routine` | Change sleep, limits or one activity (scheduler/admin) |
| `reset_routine` | Restore the defaults (scheduler/admin) |
### Admin
| Tool | Notes |
|---|---|
| `delete_event` | destructive; two-phase confirmation |
| `connect_account` | returns the command for the user to run; consent happens in their browser |
| `disconnect_account` | destructive; revokes at Google then deletes locally |
| `set_account_role` | warns if the role exceeds the granted OAuth scopes |
| `set_account_enabled` | 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_account` returns the exact command instead.
- **`delete_calendar` / ACL editing.** Out of scope, and the reason the broad
`calendar` scope 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_conflicts` with `accounts: ["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
> reports `checked_accounts` so you can see nothing was missed.
> **"Create this event on my work calendar."**
> `create_event` with `account: "work"` → created; the response says
> `account_resolution: "explicit"`.
> **"Create a meeting tomorrow at 10am."** *(no calendar named)*
> `create_event` with 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."**
> ```json
> { "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: `work` is `editor`, and deletion is admin-only.
> **"Why can't you delete that?"**
> `describe_permissions` → the full role table and this session's role.
---
## Testing
```bash
npm test # 195 tests
npm run test:watch
npm run typecheck
npm run doctor # live check against Google
```
| Suite | Covers |
|---|---|
| `tests/unit/rbac.test.ts` | role expansion, the four gates, the account matrix |
| `tests/unit/account-isolation.test.ts` | per-account vaults, crossed-token detection, traversal, concurrency |
| `tests/unit/routing.test.ts` | ambiguity refusal, explicit routing, no fallback |
| `tests/unit/confirm.test.ts` | token binding, replay across accounts, expiry, single use |
| `tests/unit/scheduling.test.ts` | interval algebra, conflicts, slots, DST, `+05:45` zones |
| `tests/unit/auth.test.ts` | client binding, refresh persistence, crypto, redaction |
| `tests/unit/tool-exposure.test.ts` | visibility per role, annotation correctness |
| `tests/unit/planner.test.ts` | placement rules, consistency, WFA gaps, overload honesty |
| `tests/integration/tools.test.ts` | real handlers end-to-end with a fake Google layer |
| `tests/integration/planning.test.ts` | 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_DENIED`
- editor can create and update, **cannot** delete
- scheduler can schedule; admin can manage accounts
- `personal` token → `personal` vault only; a swapped file is rejected
- a confirm token from `business` cannot delete on `work`
- an 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](#6-publish-the-app--do-not-skip-this)), 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
1. `npm run build` — the config points at `dist/`, not `src/`.
2. Use absolute paths in `claude_desktop_config.json`.
3. Restart Claude Desktop fully.
4. Check `MCP_PRINCIPAL_ROLE`: `viewer` legitimately hides every write tool.
5. Run the server manually — it prints startup errors to stderr:
```bash
node dist/index.js
```
6. Claude 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:
```bash
npm run auth -- --account work --role editor --access read_write
```
### `CALENDAR_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 |
|---|:--:|---|---|
| `GOOGLE_CLIENT_ID` | ✅ | — | OAuth client id |
| `GOOGLE_CLIENT_SECRET` | ✅ | — | OAuth client secret |
| `MCP_MASTER_KEY` | ✅ | — | token-vault encryption passphrase |
| `MCP_PRINCIPAL_ROLE` | | `admin` | session role ceiling |
| `MCP_DEFAULT_TIMEZONE` | | host zone | IANA zone, e.g. `Asia/Jakarta` |
| `MCP_DATA_DIR` | | `./data` | registry and vault location |
| `MCP_OAUTH_PORT` | | `42813` | loopback port for `npm run auth` |
| `MCP_DYNAMIC_TOOL_EXPOSURE` | | `true` | hide tools the session role cannot use |
| `MCP_CONFIRMATION_TTL_SECONDS` | | `300` | confirm-token lifetime |
| `MCP_PLAN_TTL_SECONDS` | | `3600` | how long a proposed schedule stays applicable |
| `MCP_LOG_LEVEL` | | `info` | `debug`/`info`/`warn`/`error`/`silent` |
### 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.json
```
Back up `data/` **and** `MCP_MASTER_KEY` together — neither is useful alone.
`data/` is gitignored.
### Per-account options in `accounts.json`
```jsonc
{
"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, doctor
```
`rbac/` imports nothing from `google/`. The authorization layer is reusable for
any resource, and is tested without a network.
---
## Sources
- [Model Context Protocol](https://modelcontextprotocol.io/) · [TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) · [SDK v2 docs](https://ts.sdk.modelcontextprotocol.io/v2/)
- [Connect to local MCP servers](https://modelcontextprotocol.io/docs/2026-07-28/develop/connect-local-servers) · [Claude Desktop MCP guide](https://support.claude.com/en/articles/10949351-getting-started-with-local-mcp-servers-on-claude-desktop)
- [Tool annotations as risk vocabulary](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/)
- [Google Calendar API v3](https://developers.google.com/calendar/api) · [Choosing scopes](https://developers.google.com/workspace/calendar/api/auth) · [freebusy.query](https://developers.google.com/workspace/calendar/api/v3/reference/freebusy/query)
- [Using OAuth 2.0 to access Google APIs](https://developers.google.com/identity/protocols/oauth2) — refresh-token expiry and per-client limits
- [Unverified apps](https://support.google.com/cloud/answer/7454865) · [When verification is not needed](https://support.google.com/cloud/answer/13464323) · [Sensitive scope verification](https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification)
- Prior art reviewed: [nspady/google-calendar-mcp](https://github.com/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
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.