schedule-mcp: Building A Personal Scheduling Assistant with Claude and MCP
I have ADHD. That means the cognitive overhead of managing multiple tools isn't just a minor inconvenience. It can be a genuine barrier to keeping track of commitments and getting things done. For a long time, my scheduling setup looked like this: Google Calendar for actual events, Notion for appointment context and tasks, my bullet journal for daily to-do’s, and a lot of manual friction moving information between them.
I wanted one place where I could ask a question and get an answer that accounted for all of it. I also wanted to learn how to build with Anthropic's Model Context Protocol. So I built schedule-mcp: a local MCP server that gives Claude live, read-write access to my Google Calendar, Notion Appointments database, and Notion Tasks database.
This post covers what it does, how it's built, and a few of the interesting problems I ran into along the way.
What It Does
At its core, schedule-mcp lets me have conversations like this with Claude Desktop:
"What does my week look like?"
"Do I have any conflicts or back-to-backs?"
"Find me 90 minutes of free time Thursday afternoon and block it for deep work."
"I have a dentist appointment Tuesday at 2pm — add it to my calendar and Notion."
Claude handles the multi-step logic: fetching events, checking for gaps, creating records in both places, and preventing duplicates. I just describe what I want.
Architecture
The server sits between Claude and my data sources:
Google Calendar ←→ Claude (via MCP) ←→ Notion Appointments ↓ Notion Tasks
Key design decisions:
Google Calendar is the source of truth for time. It's where events actually live.
Notion Appointments is the source of truth for context — type (Medical, Personal, Work), status, notes, and recurrence metadata.
Notion Tasks are linked by URL, embedded in calendar event descriptions. No bidirectional sync needed — Claude can navigate the link.
A
GCal Event IDfield on each Notion Appointment prevents duplicate entries when syncing.
How MCP Works (The Short Version)
MCP (Model Context Protocol) is Anthropic's open standard for giving Claude structured access to external tools and data. Instead of pasting calendar info into a chat, the MCP server exposes typed tool definitions that Claude can call directly — with proper inputs, outputs, and error handling.
The server runs locally as a subprocess spawned by Claude Desktop. It communicates over stdio, which means it's simple to run but also means you can't use print() for debugging — it corrupts the protocol. Everything diagnostic goes to stderr.
def main() -> None:
print("schedule-mcp: starting up...", file=sys.stderr)
try:
mcp.run()
except KeyboardInterrupt:
pass
finally:
print("schedule-mcp: shutting down.", file=sys.stderr)
Tools are defined with FastMCP and Pydantic models for input validation:
@mcp.tool(
name="gcal_get_events",
annotations={"readOnlyHint": True, "destructiveHint": False},
)
async def gcal_get_events(params: GetEventsInput) -> str:
"""
Fetch Google Calendar events within a date range.
...
"""
events = get_events(params.start_date, params.end_date, ...)
return json.dumps([_simplify_event(e) for e in events], indent=2)
The annotations tell Claude about the nature of each tool — whether it's read-only, whether it's destructive, whether it's idempotent. This helps Claude make smarter decisions about when and how to call them.
Project Structure
schedule_mcp/ ├── pyproject.toml ├── .env.example └── schedule_mcp/ ├── server.py # Entry point, registers all tools ├── auth/ │ └── google_auth.py # Google OAuth2 flow ├── clients/ │ ├── gcal.py # Google Calendar API client │ └── notion.py # Notion API client └── tools/ ├── calendar.py # gcal_* tools ├── appointments.py # notion_*appointment* tools ├── tasks.py # notion_*task* tools └── schedule.py # Cross-source tools (week overview, conflicts, task blocks)
The separation between clients/ and tools/ is intentional. The clients are plain Python functions that talk to APIs. The tools layer wraps them with MCP tool definitions, input validation, and error handling. This makes the clients easy to test independently and keeps the tool definitions clean.
A Few Interesting Problems
1. Notion's v3 API Breaking Change
I started with notion-client and hit an immediate wall: notion.databases.query() no longer exists in v3. It moved to notion.data_sources.query(). Once I tracked that down, updating all the call sites was straightforward:
# v2 (broken)
results = notion.databases.query(database_id=DB_ID, filter=...)
# v3 (correct)
results = notion.data_sources.query(DB_ID, filter=filter)
2. Timezone Handling on Windows
Python's zoneinfo module relies on IANA timezone data, which macOS and Linux ship by default but Windows does not. The fix is a single extra dependency:
# pyproject.toml "tzdata>=2024.1"
There was also a subtler bug: I was reading the timezone from environment variables at module import time, before python-dotenv had loaded the .env file. Moving the read inside a function call fixed it:
# Bug: read at import time, before .env is loaded
LOCAL_TZ = os.environ.get("LOCAL_TIMEZONE", "America/Los_Angeles")
# Fix: read at call time
def _local_tz() -> str:
return os.environ.get("LOCAL_TIMEZONE", "America/Los_Angeles")
3. Recurring Events and the Series ID
Google Calendar represents recurring events as individual instances, each with its own id (for the specific occurrence) and a recurringEventId (for the series as a whole). When I added recurring appointment support to Notion — with a Recurring select field and a GCal Series ID text field — I needed Claude to be able to pass the series ID automatically without me looking it up.
The fix was simple: surface recurringEventId in the simplified event output that Claude sees:
def _simplify_event(event: dict) -> dict: return { "id": event.get("id"), # occurrence ID "title": event.get("summary", "(no title)"), "start": ..., "end": ..., # ... "recurring_event_id": event.get("recurringEventId"), # series ID }
Now when Claude syncs a recurring GCal event to Notion, it can pass recurring_event_id → gcal_series_id automatically.
Running It
The server uses uv for dependency management and runs as a local subprocess via Claude Desktop's MCP config:
{ "mcpServers": { "schedule": { "command": "uv", "args": ["run", "--project", "C:/Users/YOU/source/repos/schedule_mcp", "schedule-mcp"], "env": { "GOOGLE_TOKEN_FILE": "C:/Users/YOU/.schedule_mcp/google_token.json", "GOOGLE_CREDENTIALS_FILE": "C:/Users/YOU/.schedule_mcp/google_credentials.json", "NOTION_TOKEN": "your_token_here", "NOTION_APPOINTMENTS_DB_ID": "your_db_id", "NOTION_TASKS_DB_ID": "your_db_id", "LOCAL_TIMEZONE": "America/Los_Angeles" } } } }
The first run opens a browser for Google OAuth consent. After that, the token is saved and auto-refreshed.
What I'd Build Next
A few things I’d like to add:
Selective bulk import — syncing a filtered subset of GCal events to Notion in one shot
A web or mobile interface so this isn't limited to Claude Desktop
Automation to create a daily/weekly summary page that can be added in my Notion and/or emailed to me
Code
The full project is on GitHub. It's set up to be cloned and adapted — the Notion field names and status values are the main things you'd need to align with your own databases.
If you're interested in building with MCP, this is a good starter pattern: a local server, OAuth-authenticated external APIs, Pydantic-validated tools, and a clean client/tool separation that scales as you add more integrations.