Skip to main content
Glama

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

  2. Quick start

  3. Google Cloud setup

  4. Connecting five accounts

  5. Connecting to Claude

  6. Planning assistant

  7. Roles and permissions

  8. Security model

  9. Tools

  10. Example prompts

  11. Testing

  12. Troubleshooting

  13. Configuration reference


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 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.

  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.

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:

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:

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

{
  "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.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."

{ "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

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), 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:

    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:

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

{
  "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


Licence

MIT

Available Tools

25 tools
apply_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/busyA
Read-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 eventA
Destructive

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 permissionsA
Read-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 accountA
Destructive

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 slotsA
Read-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 conflictsA
Read-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 detailsA
Read-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 eventA
Read-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 profileA
Read-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 accountsA
Read-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 listA
Read-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 calendarsA
Read-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 eventsA
Read-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 scheduleA
Read-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 weekA
Read-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 eventsA
Read-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

    A3.9/5.0

    Scored across 25 tools

    Disambiguation4/5

    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.

    Naming Consistency5/5

    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'.

    Tool Count3/5

    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.

    Completeness4/5

    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

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables 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.
      1
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables 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