Metadata-Version: 2.4
Name: calendar-mcp-server
Version: 1.1.0
Summary: A single-process MCP server that gives LLM clients read and write access to Google Calendar.
License-Expression: AGPL-3.0
Project-URL: Homepage, https://github.com/deciduus/calendar-mcp
Project-URL: Repository, https://github.com/deciduus/calendar-mcp
Keywords: mcp,google-calendar,model-context-protocol
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Scheduling
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mcp<3,>=2
Requires-Dist: google-api-python-client
Requires-Dist: google-auth-oauthlib
Requires-Dist: google-auth-httplib2
Requires-Dist: python-dateutil
Requires-Dist: pydantic[email]>=2
Requires-Dist: python-dotenv
Requires-Dist: requests
Requires-Dist: tzdata
Requires-Dist: platformdirs
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Requires-Dist: anyio; extra == "dev"
Dynamic: license-file

# calendar-mcp

An MCP server that gives an LLM client read and write access to your Google Calendar. It runs as a single process — stdio by default, streamable HTTP optionally — and exposes 23 tools with structured output: listing and searching calendars and events, creating, updating, moving, RSVPing to and deleting events, free/busy queries, busyness analysis, recurring-event projection, and finding a mutual slot and booking it. On top of that it has a scheduling brain that knows your working hours: finding and booking focus time, detecting double-bookings across several accounts at once, proposing better times for a meeting, and auditing where your week actually went. Authentication is Google OAuth 2.0 (Desktop app flow); tokens are cached locally, per account, and refreshed automatically.

## Quick start

**1. Create Google OAuth credentials.** In the [Google Cloud console](https://console.cloud.google.com/), enable the Google Calendar API, then create an OAuth client ID of type **Desktop app**. Copy the client ID and secret. (Details in [Google Cloud setup](#google-cloud-setup).)

**2. Set them in your environment**, or in a `.env` file in the directory you run from (see `example.env`):

```dotenv
GOOGLE_CLIENT_ID='...'
GOOGLE_CLIENT_SECRET='...'
```

**3. Sign in once, then add the server to your client:**

```bash
uvx calendar-mcp-server auth
```

This opens a browser and saves a token in the config directory (see [Accounts](#accounts)); if you already have a `.gcp-saved-tokens.json` or set `TOKEN_FILE_PATH`, that file is used instead. Verify it with `calendar-mcp check`. Add more Google accounts with `calendar-mcp auth --account work`. After that the server runs non-interactively — it never opens a browser on its own unless you set `CALENDAR_MCP_ALLOW_BROWSER_AUTH=1`.

> The PyPI distribution is `calendar-mcp-server`. It installs two identical console scripts, `calendar-mcp` and `calendar-mcp-server`, so `uvx calendar-mcp-server` and a local `calendar-mcp` are the same command.

## Client configuration

**Claude Code**

```bash
claude mcp add calendar \
  --env GOOGLE_CLIENT_ID=... \
  --env GOOGLE_CLIENT_SECRET=... \
  -- uvx calendar-mcp-server
```

**Claude Desktop** — `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "calendar": {
      "command": "uvx",
      "args": ["calendar-mcp-server"],
      "env": {
        "GOOGLE_CLIENT_ID": "...",
        "GOOGLE_CLIENT_SECRET": "...",
        "TOKEN_FILE_PATH": "/absolute/path/to/.gcp-saved-tokens.json"
      }
    }
  }
}
```

**Cursor** — `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global): same `mcpServers` block as above.

**Any other client** that speaks the standard `mcpServers` JSON takes the same entry. `TOKEN_FILE_PATH` is optional now that tokens default to the OS config directory, which does not depend on the working directory the client picks — but if you do set it, make it an absolute path.

## Remote / HTTP mode

```bash
calendar-mcp --transport http --host 127.0.0.1 --port 8000
```

The MCP endpoint is then `http://127.0.0.1:8000/mcp` (change the path with `--path`).

**There is no authentication layer on the HTTP transport yet.** Anyone who can reach the endpoint gets full access to the calendar the saved token belongs to. Bind it to loopback, or expose it only behind a trusted reverse proxy that authenticates, or on a private network such as a tailnet. Do not put it on a public interface.

## Tools

Read-only tools never change anything. "Writes" tools create or modify events;
`delete_event` is the only one that destroys data.

**Calendars and events**

| Tool | | Description |
| --- | --- | --- |
| `list_calendars` | read | List the calendars the user can see, with IDs and timezones. |
| `find_events` | read | Search a calendar for events, expanding recurring series into instances. |
| `check_attendee_status` | read | Report who accepted, declined or has not answered an invitation. |
| `query_free_busy` | read | Busy intervals for one or more calendars, without event details. |
| `analyze_busyness` | read | Per-day event count and total scheduled minutes over a range. |
| `project_recurring_events` | read | Compute future occurrences from recurrence rules. |
| `create_calendar` | writes | Create a new secondary calendar. |
| `create_event` | writes | Create an event with explicit start/end times and optional attendees. |
| `quick_add_event` | writes | Create an event from a plain-English phrase, parsed by Google. |
| `update_event` | writes | Change fields on an event; omitted fields are left untouched. |
| `move_event` | writes | Reschedule an event, and/or move it to another calendar. |
| `add_attendee` | writes | Invite one or more people to an existing event. |
| `respond_to_event` | writes | Set your own RSVP (`accepted`/`declined`/`tentative`/`needsAction`). |
| `schedule_mutual` | writes | Find the first slot where everyone is free, then book it. |
| **`delete_event`** | **destroys** | Permanently delete an event. Asks the client to confirm via elicitation when supported. |

**Scheduling brain** — these read your saved preferences (working hours, lunch, buffer, minimum focus block).

| Tool | | Description |
| --- | --- | --- |
| `find_focus_time` | read | Uninterrupted blocks in a window that could be used for deep work, longest first. |
| `detect_conflicts` | read | Double-bookings and too-tight transitions, across every signed-in account at once. |
| `time_audit` | read | Where the time went: meeting hours, by size, domain, recurrence and person. |
| `suggest_reschedule` | writes *(opt-in)* | Ranked better times for an existing meeting. Suggests only, unless `apply: true`. |
| `block_focus_time` | writes | Book the best free blocks as focus time. `dry_run: true` to preview. |

**Local configuration** — no Google call, no `account` argument.

| Tool | | Description |
| --- | --- | --- |
| `list_accounts` | read | The accounts you have signed in, and which is the default. |
| `get_preferences` | read | Working hours, lunch, buffer, minimum focus block, focus calendar. |
| `set_preferences` | writes | Update and save those preferences. Local file only. |

Every calendar tool takes an optional trailing `account` argument (`detect_conflicts`
takes `accounts`, a list, because checking several at once is the point). All times
are ISO 8601 strings — a naive timestamp is interpreted in the target calendar's own
timezone.

### Scheduling brain

The five scheduling tools share one idea: your calendar is not the same as your
availability. They start from your working hours, subtract lunch, subtract what is
already booked, and subtract the buffer you want around meetings — then reason about
what is left.

Prompts that exercise them:

- "Find me six hours of focus time next week and block it out — show me the times first."
- "Is anything double-booked between my work and personal calendars this week?"
- "My Thursday is back-to-back. Suggest better times for the design review."
- "Where did my time go last month? Who am I spending it with?"
- "I need a 90-minute deep work block before Friday. Is there one?"

`find_focus_time` reports what exists; `block_focus_time` defends it by creating
events (busy, reminders off, nobody notified), trimming the last block so it books
exactly the hours you asked for rather than a whole afternoon. `detect_conflicts`
separates genuine overlaps from *tight* transitions that merely break your buffer,
and ignores events you declined, events marked free, and (by default) all-day
entries. `suggest_reschedule` keeps the meeting's duration, ranks candidate slots by
fewest attendee conflicts and prefers the event's current day, and moves nothing
unless you pass `apply: true`.

### Time audit

`time_audit` answers "how much of my week is meetings?" in one pass, grouped by
`day` or `week`:

```
2026-08-01 .. 2026-09-01, Europe/Berlin, grouped by week
  Meetings:         41.5 h across 63 meetings
  Working hours:    152.0 h available (lunch removed)
  In working hours: 38.0 h -> 25% of the week
  Busiest week:     2026-W34, 14.0 h
  Heaviest day:     2026-08-20, 6.5 h
  By size:          1:1 18.0 h | small 15.5 h | large 8.0 h
  By recurrence:    recurring 26.0 h | one-off 15.5 h
  Top people:       a.schmidt@example.com 9.0 h | j.lee@example.com 7.5 h
```

Declined meetings, events marked free and all-day entries are left out by default;
`include_declined` and `include_all_day` bring them back.

### Safety

- **`delete_event` is the only destructive tool.** It asks the client to confirm
  through MCP elicitation when the client supports it, and proceeds normally when
  it does not.
- **`block_focus_time` takes `dry_run`.** Run it with `dry_run: true` to see the
  exact blocks it would book before anything is written.
- **`suggest_reschedule` does not move anything by default.** `apply` is `false`
  and has to be set explicitly, once the user has agreed to a time.
- **Everything else that writes is additive** — creating or editing an event —
  and `update_event` leaves fields you omit untouched.
- **`list_accounts`, `get_preferences` and `set_preferences` never touch Google.**
  They read and write local files in the config directory.

## Accounts

You can sign in more than one Google account and pick between them per call.

```bash
calendar-mcp auth                    # the default account
calendar-mcp auth --account work     # a second, named account
calendar-mcp accounts                # list them, with token paths and sign-in state
```

Every calendar tool takes an optional `account` argument naming one of these
("what's on my work calendar tomorrow?"). Omit it and the server uses the
default: `CALENDAR_MCP_DEFAULT_ACCOUNT` if set, otherwise the account named
`default`, otherwise the only account you have signed in. `list_accounts`
returns the same list the CLI prints, so the model can discover the names
itself.

Account names must match `[A-Za-z0-9][A-Za-z0-9_-]{0,63}`.

**Where things live.** Tokens and preferences are stored in the OS user config
directory — `%LOCALAPPDATA%\calendar-mcp` on Windows, `~/.config/calendar-mcp`
on Linux, `~/Library/Application Support/calendar-mcp` on macOS — with one
token file per account under `accounts/`. Override the whole directory with
`CALENDAR_MCP_CONFIG_DIR`.

**Back-compat.** `TOKEN_FILE_PATH` still works and now means *the default
account's token*. If it is set, or if a `.gcp-saved-tokens.json` exists in the
working directory, that file is used for the `default` account and nothing
moves. Named accounts always live in the config directory.

## Preferences

The server remembers how you like your week to be shaped, so the scheduling
tools do not have to guess. Read them with `get_preferences` and change them
with `set_preferences` ("I start at 8 and I want 15 minutes between meetings").
Preferences are global — they describe you, not one account — and are stored as
`preferences.json` in the config directory.

| Field | Default | Meaning |
| --- | --- | --- |
| `timezone` | unset | IANA zone the working hours are expressed in, e.g. `Europe/Berlin`. |
| `working_hours` | Mon–Fri 09:00–17:00 | Per weekday (`mon`…`sun`), a list of `["HH:MM", "HH:MM"]` spans. |
| `buffer_minutes` | `0` | Gap to leave either side of a meeting when proposing times. |
| `min_focus_block_minutes` | `60` | Shortest free stretch that still counts as usable focus time. |
| `lunch` | unset | A daily break carved out of the working hours. |
| `focus_calendar_id` | `primary` | Calendar that focus blocks are booked on. |

```json
{
  "timezone": "Europe/Berlin",
  "working_hours": {
    "mon": [["09:00", "12:00"], ["13:00", "18:00"]],
    "tue": [["09:00", "17:00"]],
    "wed": [["09:00", "17:00"]],
    "thu": [["09:00", "17:00"]],
    "fri": [["09:00", "15:00"]],
    "sat": [],
    "sun": []
  },
  "buffer_minutes": 15,
  "min_focus_block_minutes": 90,
  "lunch": ["12:30", "13:15"],
  "focus_calendar_id": "primary"
}
```

`set_preferences` merges: only the arguments you pass change, and the merged
result is validated before it is written, so a rejected change leaves the saved
file untouched. The one exception is `working_hours`, which is a **whole-schedule
replacement** — weekdays you leave out of the dict become non-working days. Pass
`clear_lunch: true` to remove a lunch break.

## Configuration

| Variable | Default | Purpose |
| --- | --- | --- |
| `GOOGLE_CLIENT_ID` | — | OAuth client ID (required). |
| `GOOGLE_CLIENT_SECRET` | — | OAuth client secret (required). |
| `TOKEN_FILE_PATH` | `.gcp-saved-tokens.json` | Where the **default** account's OAuth token is cached. |
| `CALENDAR_MCP_CONFIG_DIR` | OS user config dir | Directory holding per-account tokens (`accounts/`) and `preferences.json`. |
| `CALENDAR_MCP_DEFAULT_ACCOUNT` | `default` | Account used when a tool's `account` argument is omitted. |
| `CALENDAR_SCOPES` | `https://www.googleapis.com/auth/calendar` | Scope requested. Use `.../auth/calendar.readonly` for read-only. |
| `OAUTH_CALLBACK_PORT` | `8080` | Local port for the OAuth callback during `calendar-mcp auth`. |
| `CALENDAR_MCP_ALLOW_BROWSER_AUTH` | unset | Set to `1` to let the server itself open a browser when no token exists. Off by default so a stdio server never hangs. |
| `CALENDAR_MCP_LOG_FILE` | unset | Mirror the stderr log to this file. |
| `CALENDAR_MCP_LOG_LEVEL` | `INFO` | `DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`. Overrides `--log-level`. |
| `HOST` | `127.0.0.1` | Default for `--host` in HTTP mode. |
| `PORT` | `8000` | Default for `--port` in HTTP mode. |

A `.env` file in the working directory is loaded on startup. Logs never go to stdout — stdout is the MCP protocol channel in stdio mode.

### Commands

```
calendar-mcp [--transport {stdio,http}] [--host H] [--port P] [--path /mcp] [--log-level L]
calendar-mcp serve ...     # explicit form of the default
calendar-mcp auth [--account NAME] [--no-browser]
calendar-mcp accounts      # list known accounts; exit 1 if none is signed in
calendar-mcp check [--account NAME]   # token status + calendar list; exit 1 if no valid token
calendar-mcp --version
```

`python -m calendar_mcp` accepts the same arguments.

## Google Cloud setup

1. Create or select a project and **enable the Google Calendar API**.
2. **APIs & Services → Credentials → Create credentials → OAuth client ID → Application type: Desktop app.** Copy the client ID and secret. There is no JSON download to keep.
3. A Desktop app client has **no "Authorized redirect URIs" field** — Google permits `http://localhost` on any port for this client type, which is what the local callback on `OAUTH_CALLBACK_PORT` uses. Nothing to configure there.
4. On the **OAuth consent screen**: User Type *External*, fill in the app name and contact emails, add the `https://www.googleapis.com/auth/calendar` scope, and **add your own Google account as a test user**. Without that last step the sign-in is rejected.

## Development

```bash
git clone https://github.com/deciduus/calendar-mcp
cd calendar-mcp
uv venv
uv pip install -e ".[dev]"
pytest
```

Layout: `calendar_mcp/server.py` (the `MCPServer`, shared helpers and the credential provider), `tools/` (one module per tool area — the tool functions themselves), `calendar_actions.py` (Google API calls), `analysis.py`, `timeutil.py` (pure interval maths), `accounts.py` (multi-account token paths), `preferences.py` (the saved schedule), `models.py` (pydantic input/output models), `auth.py` (OAuth), `cli.py` (the `calendar-mcp` command). `scripts/smoke_stdio.py` spawns a real stdio server and checks the handshake and tool list.

## Upgrading from 1.0

Nothing breaks. All 15 original tools keep their names and their existing
parameters; each simply gained an optional trailing `account`. Your existing
`TOKEN_FILE_PATH` keeps working, and now names the default account's token.
What is new: multiple accounts, saved scheduling preferences, and eight new
tools (`find_focus_time`, `block_focus_time`, `detect_conflicts`,
`suggest_reschedule`, `time_audit`, `list_accounts`, `get_preferences`,
`set_preferences`).

## Upgrading from 0.x

- **Package and command renamed.** The distribution is now `calendar-mcp-server` and installs `calendar-mcp` (and an identical `calendar-mcp-server` alias). Point your client at `uvx calendar-mcp-server` instead of `python /path/to/run_server.py`.
- **`run_server.py` still works** — it is now a thin shim over the CLI — but it is deprecated and will be removed in a future release.
- **The FastAPI/uvicorn HTTP API is gone.** There are no REST endpoints, no `/health`, and no separate stdio bridge process; the server is one process on the MCP SDK. If you want HTTP, it is now MCP streamable HTTP at `/mcp`.
- **Authentication no longer happens implicitly.** Run `calendar-mcp auth` once; the server will not open a browser unless `CALENDAR_MCP_ALLOW_BROWSER_AUTH=1`.
- **Tool names are unchanged**, so existing prompts keep working. Results are now structured output rather than JSON stuffed into text.
- **Three new tools:** `move_event`, `respond_to_event`, and `project_recurring_events` (the last previously existed only as internal logic).
- Removed env vars: `RELOAD`, `MCP_API_HOST`. `HOST`/`PORT` now apply to the MCP HTTP transport.

## License

This project is dual-licensed to support both open-source collaboration and sustainable development:

1.  **GNU Affero General Public License v3.0 (AGPL-3.0):**
    *   This software is free to use, modify, and distribute under the terms of the AGPLv3 license. 
    *   Key conditions include that derivative works (including modifications used over a network) must also be licensed under AGPLv3 and their source code made available.
    *   This license is suitable for open-source projects or internal use where AGPLv3 compliance is feasible.
    *   See the [LICENSE](LICENSE) file for the full text.

2.  **Commercial License:**
    *   If the terms of the AGPLv3 are not suitable for your specific use case (e.g., integrating this software into a proprietary, closed-source commercial product or service without complying with AGPLv3's source-sharing requirements), a separate commercial license is available.
    *   Please contact **deciduusleaf@gmail.com** for inquiries regarding commercial licensing options.

By using, modifying, or distributing this software, you agree to be bound by the terms of either the AGPLv3 or a separately negotiated commercial license.

<!-- MCP Registry ownership marker; do not remove -->
mcp-name: io.github.deciduus/calendar-mcp
