mcp-ical-swift
by Sealjay
README.md
# mcp-ical-swift
[](https://glama.ai/mcp/servers/Sealjay/mcp-ical-swift)
[](https://bun.sh)
[](https://swift.org)
[](https://modelcontextprotocol.io/)
[](LICENCE)
[](https://www.apple.com/macos/)
[](https://github.com/Sealjay/mcp-ical-swift/issues)
[](https://github.com/Sealjay/mcp-ical-swift)
> A local MCP server for Apple Calendar that uses a compiled Swift binary to bypass macOS TCC restrictions blocking headless processes from accessing calendars.
mcp-ical-swift has two parts: a Bun/TypeScript MCP server that exposes calendar tools over stdio, and a compiled Swift binary that accesses EventKit directly. The Swift binary works because `swiftc`-compiled executables are Apple-signed and inherit Calendar TCC from the system toolchain — bypassing the restrictions that block Node, Bun, Python, and AppleScript in headless contexts. Everything runs locally — no network, no API keys, no cloud.
## Features
- List all calendars
- List events within a date range
- Search events by keyword
- Get full event details by UID
- Create, update, and delete events (opt-in via `ICAL_ALLOW_WRITE=true`)
- Runs entirely locally over stdio — no network, no API keys, no cloud
## Setup
### Prerequisites
- macOS with Xcode Command Line Tools (`xcode-select --install`) for `swiftc`
- [Bun](https://bun.sh) 1.1+
- Calendar data in Apple Calendar (iCloud, Exchange, or local calendars)
### Installation
1. **Clone this repository**
```bash
git clone https://github.com/Sealjay/mcp-ical-swift.git
cd mcp-ical-swift
```
2. **Install dependencies and build**
```bash
bun install
bun run build
```
The build step compiles `src/calendar-reader.swift` into `bin/calendar-reader`.
## MCP client configuration
All clients use the same `command`/`args` shape. On macOS, you may need the absolute path to `bun` — see [macOS: `bun` PATH](#macos-bun-path) below.
### Claude Code
The quickest route is the CLI:
```bash
claude mcp add --transport stdio ical --scope user -- bun run /absolute/path/to/mcp-ical-swift/src/index.ts
```
With write operations enabled:
```bash
claude mcp add --transport stdio ical --scope user -e ICAL_ALLOW_WRITE=true -- bun run /absolute/path/to/mcp-ical-swift/src/index.ts
```
Alternatively, add to `.mcp.json` at your project root (or `~/.claude.json` for user scope):
```json
{
"mcpServers": {
"ical": {
"type": "stdio",
"command": "bun",
"args": ["run", "/absolute/path/to/mcp-ical-swift/src/index.ts"]
}
}
}
```
### Claude Desktop
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"mcp-ical": {
"command": "/opt/homebrew/bin/bun",
"args": ["run", "/absolute/path/to/mcp-ical-swift/src/index.ts"],
"env": { "ICAL_ALLOW_WRITE": "true" }
}
}
}
```
If you want read-only access, omit the `env` block. Restart Claude Desktop. You should see your configured MCP entry (for example `mcp-ical`) listed as an available integration.
### Cursor
Add to `~/.cursor/mcp.json`:
```json
{
"mcpServers": {
"ical": {
"command": "bun",
"args": ["run", "/absolute/path/to/mcp-ical-swift/src/index.ts"]
}
}
}
```
Restart Cursor.
### macOS: `bun` PATH
GUI apps (Claude Desktop, Cursor) don't always inherit PATH from your interactive terminal, so `bun` may fail with `spawn bun ENOENT`. Fix by using the absolute path in `command`:
- **Apple Silicon Homebrew** — `/opt/homebrew/bin/bun`
- **Intel Homebrew** — `/usr/local/bin/bun`
- **Manual install** — run `which bun` in your terminal
### Write operations
Write tools (`ical__create_event`, `ical__update_event`, `ical__delete_event`) are disabled by default. Set `ICAL_ALLOW_WRITE=true` to enable them:
```bash
ICAL_ALLOW_WRITE=true bun run start
```
Or in your MCP client config:
```json
{
"mcpServers": {
"ical": {
"command": "bun",
"args": ["run", "/absolute/path/to/mcp-ical-swift/src/index.ts"],
"env": { "ICAL_ALLOW_WRITE": "true" }
}
}
}
```
## Architecture
| Component | Description |
|-----------|-------------|
| MCP server | Bun/TypeScript, stdio transport, Zod input validation |
| Swift binary | Compiled with `swiftc`, accesses EventKit framework |
| Communication | `execFileSync` with array args (no shell interpolation) |
| Output | JSON via `JSONSerialization` (prevents injection from user strings) |
### Data flow
```
MCP Client (Claude, Cursor, etc.)
→ stdio → Bun MCP server (src/index.ts)
→ execFileSync → compiled Swift binary (bin/calendar-reader)
→ EventKit → Apple Calendar
```
The Swift binary is compiled once (`bun run build`) and called synchronously for each tool invocation. It outputs JSON to stdout, which the MCP server wraps in tool results.
### Project structure
```
mcp-ical-swift/
src/
index.ts # MCP server entry point, tool definitions, Zod schemas
calendar-reader.swift # Swift EventKit binary source
Info.plist # Embedded in the binary; carries the Calendar usage strings
scripts/
grant.sh # One-off Calendar TCC grant; chained off `bun run build`
bin/
calendar-reader # Compiled binary (gitignored)
package.json
```
### Why a compiled Swift binary?
On macOS Sequoia and later, headless processes cannot obtain Calendar access through TCC (Transparency, Consent, and Control):
- **EventKit from Python** (PyObjC) requires `kTCCServiceCalendar`, which needs a system dialog that headless processes cannot trigger.
- **AppleScript via `osascript`** requires `kTCCServiceAppleEvents`, attributed to the calling binary (`node`/`bun`). Direct TCC database edits are silently ignored.
- **icalBuddy** and similar Homebrew tools use EventKit internally and hit the same wall.
A `swiftc`-compiled binary produces an Apple-signed Mach-O executable that inherits Calendar TCC from the system Swift toolchain. When spawned via `execFileSync`, EventKit reports `authorizationStatus = .fullAccess`.
Under Claude Cowork, EventKit attributes the access prompt to Claude.app — the GUI process at the top of the launch chain — which ships no calendar usage-description string, so macOS silently denies access without ever showing a prompt. Nor can it be granted by hand: the Calendars pane in System Settings lists only apps that have successfully requested access, and unlike Full Disk Access it has no "+" button. `tccutil` has no grant verb, and direct TCC database edits are rejected. The fix therefore has to live here, not in the host app.
The binary re-spawns itself on startup with the private `responsibility_spawnattrs_setdisclaim` attribute (the same technique LLDB and Qt Creator use), making the child its own responsible process so macOS reads the embedded `src/Info.plist` usage strings instead of the host app's. If the private symbol is unavailable or the spawn fails, it falls back silently to the legacy behaviour — no functional regression.
Note the signature this expects: `responsibility_spawnattrs_setdisclaim` takes a `posix_spawnattr_t *`, and `posix_spawnattr_t` is itself a pointer typedef, so it must be passed `&attr` rather than `attr`. Passing the attribute by value returns `EINVAL` (22) and drops silently onto the legacy path — which looks like the fix working everywhere the host happens to have Calendar access, and failing only under Cowork.
Because the binary is ad-hoc signed, TCC pins the grant to its path and cdhash. In practice this survives rebuilds: TCC re-pins the entry to the new cdhash on the next run without re-prompting. What it cannot do is create the entry in the first place from inside an MCP client — hence `bun run grant`.
## Tools
7 tools in two groups.
### Read
| Tool | Purpose |
|---|---|
| `ical__list_calendars` | List all Apple Calendar calendars |
| `ical__list_events` | List events within a date range (YYYY-MM-DD); optional `calendar` filter and `include_notes` |
| `ical__search_events` | Search events by keyword (default: 30 days ahead); optional `calendar` filter and `include_notes` |
| `ical__get_event` | Get full details of an event by UID; optional `include_notes` |
### Write (requires `ICAL_ALLOW_WRITE=true`)
| Tool | Purpose |
|---|---|
| `ical__create_event` | Create a new event (title, start, end, calendar, location, notes, all-day) |
| `ical__update_event` | Update an existing event by UID |
| `ical__delete_event` | Delete an event by UID |
## Privacy and security
- This server accesses **all calendars** on the system by default (iCloud, Exchange, local, shared, subscribed). Filter by calendar name per-request using the optional `calendar` parameter on `list_events` and `search_events`.
- Event notes are **not included** by default — set `include_notes: true` per-request to opt in. Notes may contain sensitive data (meeting PINs, passwords, personal details).
- Write operations are gated behind `ICAL_ALLOW_WRITE=true` (off by default). Even when enabled, there is no per-operation confirmation step — an MCP client (or a prompt injection within one) could modify your calendar data.
- All communication is local over stdio — no data leaves your machine.
See [`SECURITY.md`](SECURITY.md) for how to report vulnerabilities.
## Limitations
- **Prompt-injection risk:** as with many MCP servers, this one is subject to [the lethal trifecta](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/). Event titles and notes from shared or subscribed calendars could contain adversarial content. Review risky actions before approving them in your MCP client.
- **macOS only** — requires EventKit and the Swift toolchain.
- **Synchronous execution** — the Swift binary is called once per tool invocation, not suited for high-throughput use.
- **Single-instance recurring events** — modifications apply to the individual occurrence only (`.thisEvent` span), not the series.
- **TCC dependency** — Calendar access depends on macOS granting permission to the compiled binary, which must be done once from a terminal (`bun run grant`); it cannot be approved from inside an MCP client.
## Troubleshooting
### "Calendar access is not granted"
The grant has to be established once from a terminal, where the consent prompt can actually be answered:
```bash
bun run grant
```
Approve the macOS prompt for **calendar-reader**. `bun run build` chains this automatically, so a fresh clone is granted at build time. It is a one-off — TCC re-pins the grant across rebuilds by itself.
You cannot approve this from inside an MCP client. Under Claude Cowork the request is attributed to Claude.app, which has no calendar usage-description string, so macOS denies it without showing anything, and the Calendars pane offers no way to add it by hand.
To verify:
```bash
bin/calendar-reader list-events $(date +%Y-%m-%d)
```
If no prompt ever appears, a stale entry may be suppressing it:
```bash
tccutil reset Calendar com.sealjay.mcp-ical-swift.calendar-reader
bun run grant
```
If you are on a binary built before the disclaim fix, rebuild — earlier builds passed the spawn attribute by value, which silently disabled the disclaim and left Cowork failing while every other host worked.
### Events are empty
Check that you have calendars configured in Apple Calendar:
```bash
bin/calendar-reader list-calendars
```
### Compilation fails
Ensure Xcode Command Line Tools are installed:
```bash
xcode-select --install
```
### MCP client can't launch the server
`args` must use an absolute path, not relative. If `bun` itself fails with `spawn bun ENOENT`, see [macOS: `bun` PATH](#macos-bun-path).
## Contributing
Contributions welcome via pull request. Please:
- Use conventional commits (`feat`, `fix`, `docs`, `refactor`, `test`).
- Ensure `bun test` passes.
## Licence
[MIT](LICENCE)
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues