skylight-mcp
# skylight-mcp
[](https://github.com/chrischall/skylight-mcp/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/skylight-mcp)
[](LICENSE)
MCP server for [Skylight Calendar](https://www.ourskylight.com) — 115 tools across calendar events (read+write), shared lists (read+write), chores and rewards (read+write), task-box items (read+write), meals (read+write), AI auto-creation (meal-plan + activity-idea generators with draft review/approve), messages and albums (read+write), photo/video upload, and frame/device/account settings + calendar + member management (read+write, incl. preset and custom-photo avatars).
Every API request carries the `skylight-api-version: 2026-05-01` header (matching the official mobile app); without it some features 422 with "API version does not support …".
## MCP protocol support
The server uses the official TypeScript SDK v2 and supports the stateless `2026-07-28` protocol revision over stdio. Clients can discover and call tools without an initialization handshake. Existing clients can still use the `2025-11-25` handshake.
The transport remains stdio. A host can expose it over authenticated HTTP. This package does not open a network listener.
## Auth
The server uses a headless email+password OAuth2 authorization-code flow — no SSO, no 2FA, no browser extension required. Configure it with `SKYLIGHT_REFRESH_TOKEN` if you already hold a token, or `SKYLIGHT_EMAIL` + `SKYLIGHT_PASSWORD` to log in for one.
On first tool call, the server performs four steps against `https://app.ourskylight.com`:
1. `GET /auth/session/new` — fetch the Rails CSRF token and session cookie.
2. `POST /auth/session` — log in with email + password (must happen before OAuth authorize).
3. `GET /oauth/authorize` — send an S256 PKCE `code_challenge` (the server requires it) and receive the one-time authorization code via redirect.
4. `POST /oauth/token` — exchange the code plus the matching `code_verifier` for a bearer `access_token` + `refresh_token` (currently a 24-hour expiry; the client reads the returned `expires_in` rather than assuming).
OAuth redirects stay on the authentication origin. Relative redirects resolve against the current URL. The configured callback supplies the authorization code without receiving a request or session cookie. Unexpected destinations fail without including the redirect URL in the error.
The client then refreshes the token proactively (~60 s before expiry) and reactively on any 401. No bot wall has been observed — the headless flow works directly from Node.
**No env vars → clean start:** if credentials are not set, the server still starts without error. Auth is deferred to the first tool call, so MCP hosts can complete install-time tool listing before credentials are configured.
### Using the auth helpers from your own code
The package also exports Skylight's session login and refresh, so a separate program can authenticate without copying `src/`:
```ts
import { login, refresh } from 'skylight-mcp/auth';
const tokens = await login({
authBaseUrl: 'https://app.ourskylight.com',
email: process.env.SKYLIGHT_EMAIL!,
password: process.env.SKYLIGHT_PASSWORD!,
});
// Skylight rotates refresh tokens: store the one each call returns.
const next = await refresh({ authBaseUrl: 'https://app.ourskylight.com', refreshToken: tokens.refreshToken });
```
This is the only library entry point; everything else in the package is the MCP server. It is ESM-only and ships type declarations.
## Frame model
All data in Skylight is scoped to a *frame* (the family hub device). On first use the client auto-discovers the single frame on the account. If the account has more than one frame, set `SKYLIGHT_FRAME_ID` to the frame ID you want. Every tool that reads frame-scoped data accepts an optional `frameId` arg to override the default.
## Tools
| Module | Tool | R/W | Description |
|---|---|---|---|
| frames | `skylight_list_frames` | R | List all frames on the account |
| frames | `skylight_get_frame` | R | Get details for a specific frame |
| frames | `skylight_list_frame_members` | R | List members associated with a frame |
| frames | `skylight_list_devices` | R | List physical devices linked to a frame |
| frames | `skylight_get_plus_access` | R | Get Skylight Plus subscription / entitlement status |
| frames | `skylight_get_reward_points` | R | Get reward-point balances per family member |
| frames | `skylight_get_household_config` | R | Get household configuration for the frame |
| frames | `skylight_list_calendars` | R | List the frame's calendar accounts and active calendars |
| frames | `skylight_get_event_notification_settings` | R | Get the frame's calendar-event notification settings |
| frames | `skylight_resolve_member` | R | Resolve a family-member name to its category id |
| frames | `skylight_get_calendar` | R | Get one calendar account |
| frames | `skylight_list_nudges` | R | List nudges (reminders) in a date range |
| frames | `skylight_update_frame` | W | Update frame display/sleep settings |
| frames | `skylight_rename_frame` | W | Rename a frame |
| frames | `skylight_update_profile` | W | Update the frame profile (name, birthday) |
| frames | `skylight_update_household_config` | W | Update household configuration |
| frames | `skylight_set_reminder_profile` | W | Set the global reminder cadence (interval_weeks) |
| frames | `skylight_add_webcal` | W | Subscribe the frame to a webcal/ICS calendar URL |
| frames | `skylight_update_calendar` | W | Set which sub-calendars of a connected account are active |
| frames | `skylight_delete_source_calendar` | W | Remove a connected source calendar (incl. webcal subscriptions) |
| frames | `skylight_set_default_calendar` | W | Set the default source calendar for new events |
| frames | `skylight_link_apple_calendar` | W | Link an Apple/iCloud calendar; the app-specific password comes from `SKYLIGHT_APPLE_APP_PASSWORD`, never a tool argument (confirm-gated) |
| frames | `skylight_categorize_source_calendar` | W | Attribute a source calendar's events to one or more family members |
| frames | `skylight_create_source_calendar` | W | Create a source calendar from raw provider attributes (advanced) |
| frames | `skylight_invite_user` | W | Invite a user to the frame by email (confirm-gated) |
| frames | `skylight_approve_user` | W | Approve a pending frame user (confirm-gated) |
| frames | `skylight_remove_user` | W | Remove a user from the frame (confirm-gated; the preview names the member) |
| frames | `skylight_delete_category` | W | Delete a category / family member (optional `reassign_to_category_id`, inferred; confirm-gated, the preview names the member) |
| frames | `skylight_update_family_member` | W | Update a family member's profile — birthday, dietary preferences (the name is the category label; set via `skylight_update_category`) |
| frames | `skylight_update_category` | W | Update a category — rename/recolor, or convert a label into a family-member profile (`linked_to_profile`) |
| frames | `skylight_create_category` | W | Create a category / family member (optional `linked_to_profile`, `avatar_id`) |
| frames | `skylight_list_avatars` | R | List the preset avatar library (emoji/icon images) |
| frames | `skylight_set_member_avatar` | W | Set a family member's avatar to a custom photo (confirm-gated) |
| frames | `skylight_set_device_album` | W | Set which photo album a device displays (inferred) |
| frames | `skylight_rename_device` | W | Rename a Skylight device |
| events | `skylight_list_events` | R | List calendar events within a date range |
| events | `skylight_get_event` | R | Get details for a specific event |
| events | `skylight_create_event` | W | Create a new calendar event (optional `category_ids` assigns members; confirm-gated when `invited_emails` is set, the preview names every recipient) |
| events | `skylight_update_event` | W | Update an existing calendar event (optional `category_ids` assigns members; confirm-gated when `invited_emails` is set) |
| events | `skylight_delete_event` | W | Delete a calendar event |
| events | `skylight_list_categories` | R | List event categories for a frame |
| events | `skylight_list_source_calendars` | R | List external source calendars linked to a frame |
| events | `skylight_list_recent_invited_emails` | R | List recently-invited email addresses |
| events | `skylight_update_event_notification_settings` | W | Update calendar-event notification settings |
| lists | `skylight_list_lists` | R | List all shared lists on a frame |
| lists | `skylight_get_list_items` | R | Get items in a specific shared list |
| lists | `skylight_create_list` | W | Create a new shared list (label + color + kind) |
| lists | `skylight_update_list` | W | Update a list's name, color, or type |
| lists | `skylight_delete_list` | W | Delete a shared list and its items (confirm-gated when the list has items; the preview lists them) |
| lists | `skylight_add_list_item` | W | Add an item to a shared list |
| lists | `skylight_update_list_item` | W | Rename a list item, check/uncheck it, or set its section |
| lists | `skylight_delete_list_item` | W | Delete an item from a shared list |
| lists | `skylight_delete_list_items` | W | Bulk-delete specific list items |
| lists | `skylight_move_list_item` | W | Reorder a list item |
| lists | `skylight_clear_list` | W | Remove all items from a list (single bulk delete; confirm-gated, the preview lists the items) |
| lists | `skylight_set_list_item_section` | W | Move list items into a named section (or clear it) |
| chores | `skylight_list_chores` | R | List chores within a date range |
| chores | `skylight_search_chores` | R | Search chores (incl. unscheduled/template chores) |
| chores | `skylight_create_chore` | W | Create a new chore (summary + category) |
| chores | `skylight_create_recurring_chore` | W | Create a recurring chore or routine (RRULE) |
| chores | `skylight_complete_chore` | W | Mark a chore complete |
| chores | `skylight_uncomplete_chore` | W | Reopen (un-complete) a chore |
| chores | `skylight_update_chore` | W | Update a chore (supports recurrence + `apply_to`) |
| chores | `skylight_complete_chore_instance` | W | Mark a specific recurring-chore occurrence complete |
| chores | `skylight_delete_chore` | W | Delete a chore (occurrence or whole series via `apply_to`) |
| chores | `skylight_list_rewards` | R | List rewards for a frame, each with its owner in `category_id` |
| rewards | `skylight_get_reward` | R | Get one reward |
| rewards | `skylight_create_reward` | W | Create a reward (name + description + point_value + respawn_on_redemption + category_ids) |
| rewards | `skylight_update_reward` | W | Update a reward |
| rewards | `skylight_delete_reward` | W | Delete a reward |
| rewards | `skylight_redeem_reward` | W | Redeem a reward |
| rewards | `skylight_unredeem_reward` | W | Reverse a reward redemption |
| rewards | `skylight_add_reward_points` | W | Grant or deduct reward points to members |
| meals | `skylight_list_meals` | R | List planned meals in a date range (date_min + date_max both required) |
| meals | `skylight_list_recipes` | R | List meal recipes for the frame |
| meals | `skylight_list_meal_categories` | R | List meal categories for the frame |
| meals | `skylight_get_recipe` | R | Get one meal recipe |
| meals | `skylight_create_recipe` | W | Create a meal recipe (meal_category_id + summary) |
| meals | `skylight_update_recipe` | W | Update a meal recipe |
| meals | `skylight_delete_recipe` | W | Delete a meal recipe |
| meals | `skylight_add_recipe_to_grocery_list` | W | Add a recipe's ingredients to a grocery list |
| meals | `skylight_plan_meal` | W | Plan a meal on a date (optionally repeating, link a recipe, add to grocery list) |
| meals | `skylight_update_meal` | W | Update a planned meal (name, recipe, slot, notes, date, repeat rule) at a chosen recurrence scope |
| meals | `skylight_delete_meal` | W | Remove a planned meal — one occurrence, this-and-future, or the whole series (confirm-gated) |
| messages | `skylight_list_messages` | R | List messages posted to the frame (reads the paged feed, up to 20 pages) |
| messages | `skylight_list_albums` | R | List photo albums on the frame |
| messages | `skylight_get_message` | R | Get one frame message |
| messages | `skylight_create_album` | W | Create a photo album |
| messages | `skylight_update_album` | W | Update a photo album (rename, hide from slideshow) |
| messages | `skylight_delete_album` | W | Delete a photo album |
| messages | `skylight_add_to_album` | W | Add messages/photos to albums |
| messages | `skylight_remove_from_album` | W | Remove messages/photos from albums |
| messages | `skylight_copy_messages_to_frames` | W | Copy messages/photos to other frames on the account (inferred) |
| messages | `skylight_add_message_comment` | W | Comment on a frame message/photo |
| messages | `skylight_set_message_caption` | W | Set a message/photo caption |
| messages | `skylight_like_message` | W | Like a frame message/photo |
| messages | `skylight_unlike_message` | W | Remove a like from a message/photo |
| messages | `skylight_delete_message` | W | Delete a frame message/photo |
| messages | `skylight_delete_messages` | W | Bulk-delete messages/photos from the frame (confirm-gated, the preview lists each id with its caption) |
| tasks | `skylight_list_tasks` | R | List task-box items |
| tasks | `skylight_create_task` | W | Create a task-box item |
| tasks | `skylight_update_task` | W | Update a task-box item |
| tasks | `skylight_delete_task` | W | Delete a task-box item |
| ai | `skylight_generate_meal_plan` | W | Generate an AI meal plan for given dates (draft meal sittings — async) |
| ai | `skylight_generate_activity_ideas` | W | Generate AI activity/event ideas for a location + time range (draft events — async) |
| ai | `skylight_get_auto_creation_intent` | R | Get an AI auto-creation intent (status + draft results) |
| ai | `skylight_list_auto_creation_drafts` | R | List the events an AI intent drafted (review before approving) |
| ai | `skylight_list_auto_creation_intents` | R | List all AI auto-creation intents on the frame |
| ai | `skylight_list_auto_creation_items` | R | List every draft item an AI intent created (meals, activities, list items) |
| ai | `skylight_approve_auto_creation` | W | Approve AI-drafted events into real calendar events |
| ai | `skylight_undo_auto_creation` | W | Undo/discard an AI auto-creation intent and its drafts |
| photos | `skylight_upload_photo` | W | Upload a photo/video to the frame — a local file, inline base64, or (hosted) an upload-slot `image_blob_key` (confirm-gated) |
| photos | `skylight_import_events_from_photo` | W | Import calendar events from a photo of a flyer/invite using Skylight's AI — same three file sources (best-effort; confirm-gated) |
| photos | `skylight_upload_slots` | R | Hosted deployments: mint signed upload URLs for the photo tools (PUT the file, then pass `image_blob_key`); refused without a blob store |
| health | `skylight_healthcheck` | R | Report whether the connector is working: which credential resolved, whether Skylight accepted it, and what to fix |
## Configuration
### Required — one of these two
**A refresh token you already hold** (preferred: scoped, revocable, and it never
touches the rate-limited login endpoint):
```
SKYLIGHT_REFRESH_TOKEN=your-refresh-token
```
**Or the login pair**, which mints one for you:
```
SKYLIGHT_EMAIL=you@example.com
SKYLIGHT_PASSWORD=your-password
```
Setting both is also valid, and is the most robust configuration: the token is
used first, and if it has expired the login quietly mints a replacement. With a
token alone, an expired token is reported as expired — the server says so
plainly rather than claiming it is unconfigured.
**A supplied refresh token is single-use.** Skylight rotates the refresh token
every time it is spent, so the server's first start uses up
`SKYLIGHT_REFRESH_TOKEN` and keeps working on the rotated one — which lives only
in the [token cache](#token-cache). With a token alone, keep that cache enabled
and writable, and never share one token between two hosts: a start that finds
the env token already spent cannot recover without the login pair.
### Optional
| Env var | Default | Purpose |
|---|---|---|
| `SKYLIGHT_FRAME_ID` | auto-discovered | Force a specific frame when the account has multiple |
| `SKYLIGHT_NAME` | *(none)* | Friendly label used in startup logs |
| `SKYLIGHT_BASE_URL` | `https://app.ourskylight.com/api` | Override the API base URL |
| `SKYLIGHT_APPLE_APP_PASSWORD` | *(none)* | App-specific password (from appleid.apple.com) that `skylight_link_apple_calendar` sends to Skylight. Env-only by design: it is never a tool argument, so it never passes through the model, the transcript or the host's tool-call log |
| `SKYLIGHT_APPLE_ID` | *(none)* | Apple ID email for `skylight_link_apple_calendar`; the tool's `email` argument overrides it |
| `SKYLIGHT_UPLOAD_DIR` | `~/Pictures` and `~/Downloads` (hosted: only `$MCP_DATA_DIR/uploads`, avatars only) | Directories that `skylight_upload_photo`, `skylight_import_events_from_photo` (`image_path`, local installs) and `skylight_set_member_avatar` may upload from (several separated by `:`, or `;` on Windows; `~` allowed). Any other path is refused before the confirmation preview, and the confirmed read re-checks it when the file is opened. Confinement is always on: unset means the default directories, not "anywhere" (the type, hidden-file, symlink, size and content checks always apply too) |
Treat `.env` like a password file — it is gitignored, do not commit it.
### Photo uploads on a hosted deployment
A hosted server (the claude.ai connector on mcp-host) shares no disk with you,
so `image_path` is refused there and the refusal says what to do instead. The
photo tools take exactly one of three sources:
| parameter | where | notes |
|---|---|---|
| `image_path` | local installs | a file inside `SKYLIGHT_UPLOAD_DIR`; max 200 MiB |
| `image_blob_key` | hosted | call `skylight_upload_slots` with `{files: [{filename}]}`, PUT the file to the returned `uploadUrl` (the returned `curl` does it), then pass the slot's `blobKey`; max 200 MiB (the blob gateway itself caps one object at 100 MiB) |
| `image_inline` | anywhere | `{filename, contentBase64, mimeType?}` — small files only: the whole file rides inside one tool call, and mcp-host caps a request at about 9.9 MiB |
Every route accepts only jpg/jpeg, png, heic, gif, webp, mp4 and mov, and checks
the file's leading bytes against its type. The confirmation preview carries the
file's SHA-256 and the confirmed call reads the file again, so a file that
changed in between is refused (`DRAFT_CHANGED`). A blob-store upload is deleted
once Skylight accepts the photo (the result's `blob.deleted` says whether the
delete worked) and kept if the upload fails, so the same `image_blob_key` can be
retried. `skylight_upload_slots` is refused on a server with no blob store.
### Confirmations
Some writes ask you to confirm before anything happens: uploading a local
photo or avatar; inviting, approving or removing a user; deleting a family
member; linking an Apple calendar; opening the frame to the public; creating
or updating an event with `invited_emails` (Skylight e-mails each address, and
the preview names them); the bulk deletes (`skylight_delete_messages`,
`skylight_clear_list`, and `skylight_delete_list` of a non-empty list), whose
preview lists every item that would go; and meal/chore edits or deletes whose
`apply_to` reaches past the one occurrence named. A client that can show a confirmation prompt (Claude Code) shows one, unless
`MCP_CONFIRM_ELICITATION=off`. Elsewhere the first call makes no change and returns a preview of exactly what
would be sent plus a `confirmToken`; only a repeat call with that token, and
the same arguments, performs it — once.
| variable | default | |
|---|---|---|
| `MCP_CONFIRM_MODE` | `ask-user` | What a write does on a client that cannot show a confirmation prompt (claude.ai, Claude Desktop). `ask-user`: two steps — the first call does nothing and returns a preview plus a token, and the model must get your approval in chat before calling again with it. `auto`: the same two steps, but the model may use the token after reviewing the preview itself. `refuse`: writes are refused on such clients. A client that can show prompts (Claude Code) always gets the real prompt, unless `MCP_CONFIRM_ELICITATION=off`. An unrecognised value is treated as `refuse`. |
| `MCP_CONFIRM_ELICITATION` | `on` | `off` never shows a confirmation prompt, so every client gets the `MCP_CONFIRM_MODE` path. Set it for a client that claims to support prompts but never shows one (the write hangs — opencode 2.0.x). Any other value stays `on`, with a warning on stderr. |
| `MCP_CONFIRM_TTL_SECONDS` | `600` | How long a token stays valid. |
| `MCP_CONFIRM_SECRET` | random per process | Signing key; set it only if tokens must survive a server restart. |
### Token cache
After the first login the OAuth token pair is cached at
`$MCP_DATA_DIR/.skylight-mcp/tokens.json` (falling back to `$HOME`), written
`0600`. A later start reuses it instead of re-running the four-step login —
which matters because Skylight's login endpoint rate-limits, and a hosted
server that scales to zero cold-starts constantly.
Only the tokens are written; your email and password stay in the environment.
A cached token that has expired is refreshed rather than re-logged-in, and a
refresh token the server rejects falls back to a fresh login, so a stale file
cannot lock you out.
The cache is bound to whichever credential minted it — the password pair, or the
supplied `SKYLIGHT_REFRESH_TOKEN`. Rotate that credential, or point the server at
a different account, and the cached token is discarded rather than kept in play.
Only a salted digest is stored; no email, password or supplied token reaches the
file.
Set `SKYLIGHT_TOKEN_CACHE=false` to turn it off and log in on every start, or
`SKYLIGHT_TOKEN_FILE` to put the cache somewhere specific.
If a write fails (read-only or full data dir) the server logs to stderr and
keeps working on the in-memory token — only the next start pays for it. With
the login pair that cost is one login. With only `SKYLIGHT_REFRESH_TOKEN` it
would be a lockout, because Skylight rotates that token on first use and the
rotated one would never be saved. So a token-only deployment refuses to spend
it: `SKYLIGHT_TOKEN_CACHE=false` without a login pair is a configuration error,
and the server checks the cache is writable before the first refresh, failing
the call (token unspent) if it is not. A write that fails after that check is
reported loudly on stderr.
## Local dev
```
npm install
npm run build
npm test
npm run dev # requires .env with credentials
```
Tests: vitest, 100% line/branch/function/statement coverage enforced. All tests are mocked — no network calls in CI.
Developed and maintained by AI (Claude). Use at your own discretion.
TDQS
Scored across 115 tools
Tools are organized per resource (events, chores, lists, meals, rewards, photos), and descriptions do real work to separate near-duplicates (e.g. skylight_delete_message vs skylight_delete_messages, complete_chore vs complete_chore_instance). However, with 115 tools there are genuine boundary problems: delete_list_item / delete_list_items / clear_list / delete_list, list_categories vs list_frame_members vs resolve_member, list_auto_creation_drafts vs list_auto_creation_items, and create_chore vs create_recurring_chore all require careful reading to select correctly.
Nearly everything follows the skylight_ prefix + verb_noun pattern (list_events, create_event, update_chore, delete_recipe), which is highly predictable. Minor deviations: skylight_healthcheck has no verb_noun shape, and there is inconsistent singular/plural pairing (delete_message vs delete_messages, delete_list_item vs delete_list_items).
The domain (a family smart-display hub: calendars, photos, lists, chores, meals, rewards, users, devices, AI drafts) is genuinely broad, so a large surface is defensible. But 115 tools is far past practical scope — many are niche or single-purpose (healthcheck, upload_slots, resolve_member, list_avatars, list_recent_invited_emails), and the cognitive load on an agent choosing among them is severe.
Coverage is impressive: full CRUD across events, lists, chores, meals, recipes, rewards, albums, users, plus settings, AI draft lifecycle, and upload plumbing. Gaps are minor — no standalone get_list or get_album, no get_chore by id (only search/list), and nudges are read-only (list_nudges with no create/update/delete).