Skip to main content
Glama

notion-calendar-mcp

Stdio MCP server for Notion Calendar. It talks to the private calendar-api.notion.so HTTP API (no public docs; reverse-engineered).

This project is not affiliated with, endorsed by, or supported by Notion Labs, Inc.

Warning

Use this at your own risk.

  • The Calendar HTTP API is undocumented and reverse-engineered. Endpoints, payloads, and auth can change or disappear without notice.

  • Auth is a browser session JWT that typically lasts about 5 hours. After that, calls return 401/403 until you refresh the token from a signed-in calendar.notion.so session.

  • Using an unofficial API may violate Notion’s Terms of Service. You are responsible for how you use this software.

  • Never commit session tokens, credentials.json, or .env files. Never paste JWTs into chat, issues, or pull requests.

Licensed under the MIT License.

Related MCP server: Notion MCP

Requirements

The MCP SDK is pinned to mcp==1.29.1 so Server.list_tools remains available (later 2.x releases removed that registration API).

Install

git clone https://github.com/felipeorlando/notion-calendar-mcp.git
cd notion-calendar-mcp
python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -r requirements.txt

Authentication

Provide a session JWT in one of these ways (file wins for base_url / extra headers; env can supply or override the token).

mkdir -p ~/.config/notion-calendar
cp credentials.json.example ~/.config/notion-calendar/credentials.json
chmod 600 ~/.config/notion-calendar/credentials.json

Edit ~/.config/notion-calendar/credentials.json:

{
  "authorization": "Bearer YOUR_SESSION_JWT",
  "base_url": "https://calendar-api.notion.so",
  "headers": {
    "x-timezone": "America/New_York",
    "x-client-type": "web",
    "x-client-platform": "web",
    "x-client-os": "linux"
  }
}

headers is optional. Set x-timezone to your IANA timezone. See credentials.json.example for the full placeholder shape.

How to copy the JWT from the browser:

  1. Sign in at calendar.notion.so.

  2. Open DevTools → Network.

  3. Trigger any calendar load and inspect a request to calendar-api.notion.so.

  4. Copy the Authorization value (Bearer …).

2. Environment variables

export NOTION_CALENDAR_AUTHORIZATION="Bearer YOUR_SESSION_JWT"
# or a raw token / Bearer value:
# export NOTION_CALENDAR_TOKEN="YOUR_SESSION_JWT"
# optional:
# export NOTION_CALENDAR_BASE_URL="https://calendar-api.notion.so"

Do not put real tokens in shell history files you share, or in MCP config that lives inside a git repo.

On 401/403, refresh the JWT. list_tools works without a token; tool calls fail with a clear error if it is missing or expired.

Run

.venv/bin/python server.py

The process speaks MCP over stdio (no HTTP port).

Claude Desktop

Add the server to the Claude Desktop MCP config:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json

  • Windows: %APPDATA%\Claude\claude_desktop_config.json

  • Linux: ~/.config/Claude/claude_desktop_config.json

{
  "mcpServers": {
    "notion-calendar": {
      "command": "/ABSOLUTE/PATH/TO/notion-calendar-mcp/.venv/bin/python",
      "args": ["/ABSOLUTE/PATH/TO/notion-calendar-mcp/server.py"]
    }
  }
}

Replace /ABSOLUTE/PATH/TO/notion-calendar-mcp with the real clone path. Prefer the credentials file above instead of embedding the JWT in this JSON.

Restart Claude Desktop after saving.

Cursor

User config: ~/.cursor/mcp.json. Project config: .cursor/mcp.json (do not commit tokens).

{
  "mcpServers": {
    "notion-calendar": {
      "command": "/ABSOLUTE/PATH/TO/notion-calendar-mcp/.venv/bin/python",
      "args": ["server.py"],
      "cwd": "/ABSOLUTE/PATH/TO/notion-calendar-mcp"
    }
  }
}

Restart Cursor after saving.

An agent-plugin layout (plugin.json, mcp.json, skills/notion-calendar/SKILL.md) is also included for hosts that load that format.

Tools

Call get_user first, then get_calendar_lists (or get_calendars) before event reads/writes. Prefer title/summary over raw ids when presenting results.

Tool

Kind

Purpose

get_user

read

Signed-in profile and linked calendar accounts

get_calendar_lists

read

Sidebar calendars / calendarId values

get_calendars

read

Calendar metadata (color, role, time zone)

get_events

read

Events in a time window (timeMin/timeMax ms epoch)

get_event

read

One event by id

search_events

read

Keyword search across accounts/calendars

create_event

write

Create an event (start/end only — not startTime/endTime)

update_events

write

Patch one or more events

delete_events

destructive

Delete events (confirm first)

rsvp_event

write

accepted / declined / tentative (not needsAction)

get_calendar_free_busy

read

Free/busy blocks before scheduling

get_user_preferences

read

Working hours and defaults (includes etag)

update_user_preferences

write

Update prefs (send etag from the read)

get_people

read

Paginated directory / attendees

get_contacts

read

Contact lookup (prefer get_people when paginating)

get_colors

read

Color palette

get_synchronized_calendars

read

Mirrored/synced calendars

get_calendar_resources

read

Room/resource calendars

get_group_members

read

Expand Google group invitees

export_events

read

Normalize events for export

incremental_sync

read

Advanced sync tokens; prefer get_events for normal reads

get_holds / get_hold

read

Scheduling Holds (booking pages)

create_hold / update_hold

write

Create or update a Hold

delete_hold

destructive

Delete a Hold

get_notion_session_users

read

Notion identities tied to this Calendar session

update_calendars

write

Rename/recolor calendars

insert_calendar_list

write

Subscribe to a calendar

delete_calendar_list

destructive

Unsubscribe from the sidebar list

create_event eventData needs id (uuid hex is fine), summary, start, end, status, provider, accountId, calendarId, and reminders: { "useDefault": true }. Default send_updates to none unless you intend to email guests (all / externalOnly).

Smoke test

With valid credentials:

.venv/bin/python smoke_test.py

Creates a 30-minute probe event two weeks out, then deletes it. It does not print tokens.

Security

See SECURITY.md. Short version: session JWT, ~5 hour TTL, never commit secrets, refresh on 401/403.

Contributing

See CONTRIBUTING.md.

License

MIT © Felipe Orlando

Available Tools

30 tools
create_eventA

Create a calendar event. Call after confirming calendar + time with the user. POST /v2/createEvent. eventData must include id (uuid hex ok), summary, start, end, status, provider, accountId, calendarId, reminders:{useDefault:true}. Use start/end only (never startTime/endTime). start_json/end_json: {dateTime,timeZone} or {date}. send_updates: all|none|externalOnly. Summary first.

Inputs: summary (required), end_json (required), provider (required), account_id (required), +10 more.

create_holdA

Create a Hold scheduling page. POST /v2/createHold. hold_json must include id, timeRanges, userPrimaryTimeZone (and usual Hold fields). Call when the user wants a new booking link. Alias/title first.

Inputs: hold_json (required).

delete_calendar_listA
Destructive

Unsubscribe/remove a calendar from the calendar list. POST /v2/deleteCalendarList {mutation:{provider, accountId, calendarId}}. mutation_json: JSON object. Destructive for the sidebar entry (not the calendar itself).

Inputs: mutation_json (required).

delete_eventsA
Destructive

Delete one or more events. Call only after confirming which event(s). POST /v2/deleteEvents {mutations:[{provider, accountId, calendarId, eventId, sendUpdates?}]}. mutations_json: JSON array. Destructive. sendUpdates: all|none|externalOnly.

Inputs: mutations_json (required).

delete_holdA
Destructive

Delete a Hold by holdId. POST /v2/deleteHold {holdId}. Destructive — confirm with the user first.

Inputs: hold_id (required).

export_eventsA
Read-only

Normalize/export partial event objects (ICS-oriented shape). POST /v2/exportEvents {events:[...]}. events_json: array of partial events with id/start/end/summary/…. Call when packaging events for export.

Inputs: events_json (required).

get_calendar_free_busyA
Read-only

Get free/busy blocks for calendars in a time range. Call when checking availability before scheduling. POST /v2/getCalendarFreeBusy. time_min/time_max ms epoch. calendars_json: [{accountId, calendarId}].

Inputs: time_max (required), time_min (required), calendars_json (required).

get_calendar_listsA
Read-only

List calendars for one or more accounts (sidebar calendar list). Call after get_user when you need calendarId values. POST /v2/getCalendarLists {queries:[{provider, accountId}]}. queries_json: JSON array. Calendar summary/name first.

Inputs: queries_json (required).

get_calendar_resourcesA
Read-only

List room/resource calendars for accounts. POST /v2/getCalendarResources {accounts:[accountId,...]}. accounts_json: JSON string array. Call when booking rooms.

Inputs: accounts_json (required).

get_calendarsA
Read-only

Fetch calendar metadata (colors, access role, timeZone) for accounts. POST /v2/getCalendars {queries:[{provider, accountId, query?}]}. queries_json: JSON array. Prefer get_calendar_lists for the sidebar list. Summary first.

Inputs: queries_json (required).

get_colorsA
Read-only

Fetch calendar color palette. Call when rendering/assigning calendar colors. POST /v2/getColors {}.

Inputs: no named parameters.

get_contactsA
Read-only

Fetch contacts via POST /v2/getContacts (Spectral-proven; may be alias). Call for quick contact lookup; prefer get_people for paginated directory. Optional body_json object. Display name/email first.

Inputs: body_json.

get_eventA
Read-only

Fetch one event by id. Call when you already have provider/accountId/calendarId/eventId. POST /v2/getEvent {query:{...}}. Optional userTimeZone. Summary first in any presentation.

Inputs: event_id (required), provider (required), account_id (required), calendar_id (required), +1 more.

get_eventsA
Read-only

Fetch events in a time window for specific calendars. Call after you know provider/accountId/calendarId. POST /v2/getEvents {queries:[...]}. timeMin/timeMax are ms epoch. queries_json: JSON array; provider defaults to google. Return order: summary/title first, never lead with event id.

Inputs: queries_json (required).

get_group_membersA
Read-only

Resolve Google group members for attendee expansion. POST /v2/getGroupMembers {accountId, emails:[...]}. emails_json: JSON array. Call when an invitee is a group address.

Inputs: account_id (required), emails_json (required).

get_holdA
Read-only

Fetch one Hold (scheduling page) by username, alias, or holdShortId. POST /v2/getHold. Optional time_min/time_max (ms). Call after get_holds when inspecting availability slots. Alias/name first.

Inputs: alias, time_max, time_min, username, +1 more.

get_holdsA
Read-only

List scheduling Holds (booking pages) for the signed-in user. POST /v2/getHolds {}. Call when you need hold aliases/ids before get_hold / create_hold. Name/alias first, never lead with id.

Inputs: no named parameters.

get_notion_session_usersA
Read-only

List Notion session users tied to this Calendar session. POST /v2/getNotionSessionUsers {}. Call when bridging Calendar ↔ Notion identity. Display name/email first.

Inputs: no named parameters.

get_peopleA
Read-only

List people/contacts from connected accounts (paginated). POST /v2/getPeople {accountIds?, cursor?, limit?}. Call when searching attendees by directory. Display name/email first.

Inputs: limit, cursor, account_ids_json.

get_synchronized_calendarsA
Read-only

List synchronized/mirrored calendars. Call when inspecting Calendar sync links between accounts. POST /v2/getSynchronizedCalendars {}. Name first.

Inputs: no named parameters.

get_userA
Read-only

Call first to learn who is signed in and which calendar accounts exist. POST /v2/getUser {}. Returns profile + linked accounts (title/email first). Use before get_calendar_lists / get_events. Session JWT required.

Inputs: no named parameters.

get_user_preferencesA
Read-only

Read Calendar user preferences (working hours, defaults, etag). Call before update_user_preferences (etag required for force check). POST /v2/getUserPreferences {}.

Inputs: no named parameters.

incremental_syncA
Read-only

Drive incremental calendar sync with sync tokens. POST /v2/incrementalSync {syncTokens, metadata:{caller}}. Often 400 without valid tokens from a prior sync — expose for advanced use; prefer get_events for normal reads. sync_tokens_json + optional metadata_json.

Inputs: metadata_json, sync_tokens_json.

insert_calendar_listA

Subscribe/add a calendar to the user's calendar list. POST /v2/insertCalendarList {mutation:{provider, accountId, calendarId, calendarData?}}. mutation_json: JSON object. Call when adding a shared calendar.

Inputs: mutation_json (required).

rsvp_eventA

RSVP to an event invite (accepted|declined|tentative). Call when the user wants to respond to an invitation. POST /v2/rsvpEvent. response_status must NOT be needsAction. Optional comment and counter_proposal_json {start,end}.

Inputs: event_id (required), account_id (required), calendar_id (required), response_status (required), +3 more.

search_eventsA
Read-only

Full-text search events across accounts/calendars in a time range. Call when the user asks to find meetings by keyword. POST /v2/searchEvents. time_min/time_max are ms epoch. accounts_json: [{accountId, calendarIds:[]}]. Title/summary first.

Inputs: query (required), time_max (required), time_min (required), accounts_json (required), +2 more.

update_calendarsA

Patch calendar metadata (summary, color, selected, …). POST /v2/updateCalendars {mutations:[{provider, accountId, calendarId, calendarData, colorRgbFormat?, variant?}]}. mutations_json: JSON array. Call when renaming/recoloring calendars. Summary first.

Inputs: mutations_json (required).

update_eventsA

Update one or more existing events. Call when changing time/title/details. POST /v2/updateEvents {mutations:[{provider, accountId, eventId, calendarId, eventData, userTimeZone?, sendUpdates?}]}. mutations_json: JSON array. Patch eventData with fields to change; keep summary visible. sendUpdates: all|none|externalOnly.

Inputs: mutations_json (required).

update_holdA

Update an existing Hold. POST /v2/updateHold. hold_json should include id plus fields to change (alias, timeRanges, …). Call after get_hold / get_holds.

Inputs: hold_json (required).

update_user_preferencesA

Update Calendar user preferences. Call only with an etag from get_user_preferences (412 if stale). POST /v2/updateUserPreferences {userPreferences:{...}}. user_preferences_json: full/partial prefs object including etag.

Inputs: user_preferences_json (required).

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 30 tools

    Disambiguation4/5

    Most tools target a distinct resource and action, and descriptions generally clarify intent. However, get_calendar_lists vs get_calendars and get_contacts vs get_people are close enough that an agent could misselect without reading carefully.

    Naming Consistency4/5

    The dominant verb_noun pattern (get_, create_, update_, delete_) is consistent and readable. Minor deviations like insert_calendar_list, rsvp_event, and incremental_sync break the pattern slightly but are not chaotic.

    Tool Count2/5

    At 30 tools, this exceeds the 25+ threshold and feels heavy even for a calendar/scheduling domain. Several calendar-listing and contact-lookup tools could reasonably be consolidated.

    Completeness4/5

    The surface covers events, calendars, holds, preferences, contacts, and sync well, with create/read/update/delete coverage for the main resources. Minor gaps exist, such as no dedicated create_calendar or synchronization-management tools, but core workflows are supported.

    Related MCP Connectors

    Related MCP Servers