morgen-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,5 @@
1
+ # Morgen API key — get one at https://platform.morgen.so/developers-api
2
+ MORGEN_API_KEY=
3
+
4
+ # IANA timezone for calendar operations (default: America/New_York)
5
+ MORGEN_TIMEZONE=America/New_York
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nathan Davidovich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # Morgen MCP
2
+
3
+ **Natural-language calendar and task control for Morgen in Claude Code.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/morgen-mcp)](https://www.npmjs.com/package/morgen-mcp)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+ [![MCP Compatible](https://img.shields.io/badge/MCP-compatible-green)](https://modelcontextprotocol.io)
8
+
9
+ ---
10
+
11
+ ## How It Works
12
+
13
+ Once installed, you just talk to Claude. No commands to memorize, no special syntax, no API calls to learn. You speak in plain English and Claude handles the rest.
14
+
15
+ ```
16
+ You: "What's on my calendar this week?"
17
+ You: "Add a task called 'Review contracts' due Friday, high priority"
18
+ You: "Move my 3pm to 4pm tomorrow"
19
+ You: "Mark the laundry task as done"
20
+ You: "Decline the 5pm invite"
21
+ You: "Create a 30-minute call with drew@example.com Thursday at 2pm"
22
+ ```
23
+
24
+ That's it. Claude sees your Morgen calendars and tasks, understands your schedule, and takes action -- all through natural conversation. No buttons, no UI, no context switching. You stay in your terminal and your day stays in sync.
25
+
26
+ ---
27
+
28
+ ## Why This Exists
29
+
30
+ Morgen is one of the cleanest calendar-plus-task apps on the market. It unifies Google, Outlook, iCloud, and native tasks into a single auto-scheduling interface, and it ships a genuinely well-designed public API.
31
+
32
+ This MCP wraps that API and hands the whole surface to Claude Code -- events, tasks, RSVPs, calendars, the lot. One API key, one install command, and you are talking to your calendar in natural language.
33
+
34
+ Unlike other calendar integrations that require extracting refresh tokens from browser storage or juggling multiple credentials, Morgen uses a single API key. You grab it from their developer portal, drop it in the config, and you are done.
35
+
36
+ ## Features
37
+
38
+ ### Event Tools
39
+
40
+ | Tool | Description |
41
+ |---|---|
42
+ | `list_calendars` | List all Morgen calendars with id, name, color, account, sort order, and read-only status |
43
+ | `list_events` | Fetch events in a date range across one or more calendars with full details |
44
+ | `create_event` | Create an event with title, time, participants, description, location, and recurrence |
45
+ | `update_event` | Update an event; supports `seriesUpdateMode` for editing recurring events (this, following, all) |
46
+ | `delete_event` | Delete an event by ID |
47
+ | `rsvp_event` | Accept, decline, or tentatively respond to an invitation |
48
+
49
+ ### Task Tools
50
+
51
+ | Tool | Description |
52
+ |---|---|
53
+ | `list_tasks` | List native Morgen tasks |
54
+ | `create_task` | Create a new task with title, description, due date, and priority (integer 0-9; 1 = highest, 9 = lowest) |
55
+ | `update_task` | Update an existing task -- change title, description, due date, or priority |
56
+ | `move_task` | Move a task to a different list |
57
+ | `close_task` | Mark a task as completed |
58
+ | `reopen_task` | Reopen a completed task |
59
+ | `delete_task` | Delete a task permanently |
60
+
61
+ ## Important Note About Tasks
62
+
63
+ Morgen's `/tasks` endpoints only manage **first-party native Morgen tasks** -- the ones created directly inside Morgen with `integrationId: "morgen"`. Tasks that Morgen syncs in from external providers like Todoist, Google Tasks, Microsoft To Do, or Things are fully visible in the Morgen app but are **not writable through this MCP**. The Morgen API intentionally scopes write access to its own first-party task system.
64
+
65
+ The practical takeaway: if you want Claude to create, update, and close tasks programmatically, create them as native Morgen tasks. Everything else stays read-only from Morgen's side and should be managed through that provider's own integration.
66
+
67
+ ## Quick Install
68
+
69
+ One command. That is it.
70
+
71
+ ```bash
72
+ claude mcp add morgen --env MORGEN_API_KEY=your_key_here -- npx -y morgen-mcp
73
+ ```
74
+
75
+ Then restart Claude Code and start talking to your calendar.
76
+
77
+ ## Setup
78
+
79
+ Morgen authentication is simple: one API key. No browser scraping, no refresh tokens, no Firebase.
80
+
81
+ ### Step 1: Get your Morgen API key
82
+
83
+ 1. Go to [platform.morgen.so/developers-api](https://platform.morgen.so/developers-api)
84
+ 2. Sign in with your Morgen account
85
+ 3. Generate an API key and copy it
86
+
87
+ ### Step 2: Configure
88
+
89
+ You have two options:
90
+
91
+ **Option A -- Run the setup command:**
92
+
93
+ ```bash
94
+ npx morgen-mcp setup
95
+ ```
96
+
97
+ It will prompt you for your API key and timezone, then write a `.env` file for you.
98
+
99
+ **Option B -- Create `.env` manually:**
100
+
101
+ ```bash
102
+ MORGEN_API_KEY=your_morgen_api_key_here
103
+ MORGEN_TIMEZONE=America/New_York
104
+ ```
105
+
106
+ Or pass them as environment variables in your Claude MCP config:
107
+
108
+ ```json
109
+ {
110
+ "mcpServers": {
111
+ "morgen": {
112
+ "command": "npx",
113
+ "args": ["-y", "morgen-mcp"],
114
+ "env": {
115
+ "MORGEN_API_KEY": "your_morgen_api_key_here",
116
+ "MORGEN_TIMEZONE": "America/New_York"
117
+ }
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ ### Step 3: Restart Claude Code
124
+
125
+ That is the whole setup. No token refresh cycles, no IndexedDB spelunking.
126
+
127
+ ## Configuration Reference
128
+
129
+ | Variable | Required | Description |
130
+ |---|---|---|
131
+ | `MORGEN_API_KEY` | Yes | Your Morgen API key from [platform.morgen.so/developers-api](https://platform.morgen.so/developers-api) |
132
+ | `MORGEN_TIMEZONE` | No | IANA timezone for calendar operations (default: `America/New_York`). Used for formatting event times and task due dates. |
133
+
134
+ ## Usage Examples
135
+
136
+ Once installed and configured, just talk to Claude naturally:
137
+
138
+ **Check your schedule**
139
+ > "What's on my calendar this week?"
140
+
141
+ **List calendars**
142
+ > "Which Morgen calendars do I have connected?"
143
+
144
+ **Create events**
145
+ > "Create a meeting called 'Team Sync' tomorrow at 2pm for 30 minutes"
146
+ > "Schedule a call with drew@example.com at 5:30pm today"
147
+
148
+ **Modify events**
149
+ > "Move my 3pm to 4pm"
150
+ > "Change the Team Sync description to include the agenda link"
151
+
152
+ **Handle invitations**
153
+ > "Decline the 5pm meeting"
154
+ > "Tentatively accept the all-hands on Thursday"
155
+
156
+ **Delete events**
157
+ > "Cancel the standup on Friday"
158
+
159
+ **Manage tasks**
160
+ > "Add a task called 'Review contracts' due Friday, high priority"
161
+ > "What tasks do I have open?"
162
+ > "Mark the laundry task as done"
163
+ > "Reopen the invoice follow-up task"
164
+ > "Move the recording task to my Deep Work list"
165
+
166
+ ## Rate Limits
167
+
168
+ Morgen uses a rolling point-based rate limit: **100 points per 15-minute window** per API key.
169
+
170
+ - List endpoints (`list_events`, `list_tasks`, `list_calendars`) cost **10 points** per call
171
+ - Writes (create, update, delete, close, reopen, rsvp, move) cost **1 point** per call
172
+
173
+ In practice this is generous for interactive use -- you can fire off dozens of writes in a session without getting near the ceiling. Just avoid tight polling loops on the list endpoints.
174
+
175
+ Full details: [docs.morgen.so/rate-limits](https://docs.morgen.so/rate-limits)
176
+
177
+ ## Security
178
+
179
+ Your Morgen API key grants full access to your Morgen account -- all calendars, all events, all tasks. Treat it like a password:
180
+
181
+ - Do not commit your `.env` file to version control. The included `.gitignore` excludes it, but verify.
182
+ - Do not paste your key into shared chats, issues, or screenshots.
183
+ - Rotate the key from the developer portal if you suspect it has leaked.
184
+
185
+ ## Development
186
+
187
+ ```bash
188
+ # Clone the repo
189
+ git clone https://github.com/lorecraft-io/morgen-mcp.git
190
+ cd morgen-mcp
191
+
192
+ # Install dependencies
193
+ npm install
194
+
195
+ # Configure credentials
196
+ cp .env.example .env
197
+ # Edit .env with your API key
198
+
199
+ # Run directly
200
+ npm start
201
+ ```
202
+
203
+ ## Under the Hood
204
+
205
+ The server runs as a stdio-based MCP server using the official `@modelcontextprotocol/sdk`. All Morgen operations go through raw `fetch` calls against the public API at `https://api.morgen.so/v3`, authenticated with your API key via the `Authorization: ApiKey ...` header.
206
+
207
+ No SDK middleware, no token juggling, no refresh cycles. Just a thin, predictable wrapper around endpoints that Morgen already documents.
208
+
209
+ ## License
210
+
211
+ MIT -- see [LICENSE](LICENSE) for details.
212
+
213
+ ---
214
+
215
+ Built by [Nathan Davidovich / Lorecraft](https://github.com/lorecraft-io)
package/bin/setup.js ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createInterface } from "readline";
4
+ import { writeFileSync, existsSync } from "fs";
5
+ import { resolve, dirname } from "path";
6
+ import { fileURLToPath } from "url";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const envPath = resolve(__dirname, "..", ".env");
10
+
11
+ const rl = createInterface({
12
+ input: process.stdin,
13
+ output: process.stdout,
14
+ });
15
+
16
+ function ask(question, defaultValue) {
17
+ return new Promise((resolve) => {
18
+ const prompt = defaultValue
19
+ ? `${question} (${defaultValue}): `
20
+ : `${question}: `;
21
+ rl.question(prompt, (answer) => {
22
+ resolve(answer.trim() || defaultValue || "");
23
+ });
24
+ });
25
+ }
26
+
27
+ async function main() {
28
+ console.log("");
29
+ console.log("===========================================");
30
+ console.log(" Morgen MCP - Setup Wizard");
31
+ console.log("===========================================");
32
+ console.log("");
33
+
34
+ if (existsSync(envPath)) {
35
+ const overwrite = await ask(
36
+ "A .env file already exists. Overwrite? (y/N)",
37
+ "N"
38
+ );
39
+ if (overwrite.toLowerCase() !== "y") {
40
+ console.log("\nSetup cancelled. Existing .env file preserved.");
41
+ rl.close();
42
+ return;
43
+ }
44
+ console.log("");
45
+ }
46
+
47
+ console.log("You'll need the following credential.");
48
+ console.log("See the README for detailed instructions on where to find it.");
49
+ console.log("");
50
+
51
+ const morgenApiKey = await ask(
52
+ "Morgen API key (from https://platform.morgen.so/developers-api)"
53
+ );
54
+
55
+ if (!morgenApiKey) {
56
+ console.error("\nMorgen API key is required. Setup cancelled.");
57
+ rl.close();
58
+ process.exit(1);
59
+ }
60
+
61
+ const timezone = await ask("Timezone", "America/New_York");
62
+
63
+ const envContent = `# Morgen MCP Configuration
64
+ # Generated by setup wizard
65
+
66
+ MORGEN_API_KEY=${morgenApiKey}
67
+ MORGEN_TIMEZONE=${timezone}
68
+ `;
69
+
70
+ writeFileSync(envPath, envContent, { mode: 0o600 });
71
+
72
+ console.log("");
73
+ console.log("===========================================");
74
+ console.log(" Setup complete!");
75
+ console.log("===========================================");
76
+ console.log("");
77
+ console.log(` .env written to: ${envPath}`);
78
+ console.log("");
79
+ console.log(" Next steps:");
80
+ console.log(" 1. Add the MCP server to your Claude config:");
81
+ console.log("");
82
+ console.log(" claude mcp add morgen -- npx -y morgen-mcp");
83
+ console.log("");
84
+ console.log(" 2. Or run directly:");
85
+ console.log("");
86
+ console.log(" npm start");
87
+ console.log("");
88
+
89
+ rl.close();
90
+ }
91
+
92
+ main().catch((err) => {
93
+ console.error("Setup failed:", err.message);
94
+ rl.close();
95
+ process.exit(1);
96
+ });
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "morgen-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Morgen — events, tasks, and calendar management for Claude Code via natural language",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "morgen-mcp": "src/index.js"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "bin/",
13
+ ".env.example",
14
+ "LICENSE",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "start": "node src/index.js",
19
+ "setup": "node bin/setup.js",
20
+ "test": "vitest run"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "registry": "https://registry.npmjs.org/"
28
+ },
29
+ "keywords": ["mcp", "morgen", "calendar", "tasks", "claude", "ai", "productivity", "scheduling"],
30
+ "author": "Nathan Davidovich <nate@lorecraft.io>",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/lorecraft-io/morgen-mcp"
35
+ },
36
+ "homepage": "https://github.com/lorecraft-io/morgen-mcp#readme",
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "1.29.0",
39
+ "dotenv": "17.4.0"
40
+ },
41
+ "devDependencies": {
42
+ "vitest": "4.1.2"
43
+ }
44
+ }
package/src/client.js ADDED
@@ -0,0 +1,136 @@
1
+ export const MORGEN_BASE = "https://api.morgen.so";
2
+
3
+ const RATE_LIMIT_POINTS = 100;
4
+ const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
5
+
6
+ // Rolling window of { timestamp, points } entries
7
+ let pointLedger = [];
8
+
9
+ function pruneLedger(now) {
10
+ const cutoff = now - RATE_LIMIT_WINDOW_MS;
11
+ pointLedger = pointLedger.filter((entry) => entry.timestamp > cutoff);
12
+ }
13
+
14
+ function currentPoints() {
15
+ return pointLedger.reduce((sum, entry) => sum + entry.points, 0);
16
+ }
17
+
18
+ // Walk the ledger to find the earliest moment enough old points expire
19
+ // for the incoming request to fit within the budget. A 10-point list call
20
+ // with only 5 free points needs to wait until multiple old entries drop off,
21
+ // not just the oldest one.
22
+ function msUntilFits(now, incomingPoints) {
23
+ const overBy = currentPoints() + incomingPoints - RATE_LIMIT_POINTS;
24
+ if (overBy <= 0) return 0;
25
+ let released = 0;
26
+ for (const entry of pointLedger) {
27
+ released += entry.points;
28
+ if (released >= overBy) {
29
+ const expiryTime = entry.timestamp + RATE_LIMIT_WINDOW_MS;
30
+ return Math.max(0, expiryTime - now);
31
+ }
32
+ }
33
+ return RATE_LIMIT_WINDOW_MS;
34
+ }
35
+
36
+ function enforceRateLimit(points) {
37
+ const now = Date.now();
38
+ pruneLedger(now);
39
+
40
+ if (currentPoints() + points > RATE_LIMIT_POINTS) {
41
+ const msUntilExpiry = msUntilFits(now, points);
42
+ const secondsUntilExpiry = Math.max(1, Math.ceil(msUntilExpiry / 1000));
43
+ throw new Error(
44
+ `Morgen rate limit reached (100 points per 15 minutes). Try again in ${secondsUntilExpiry} seconds.`
45
+ );
46
+ }
47
+
48
+ pointLedger.push({ timestamp: now, points });
49
+ }
50
+
51
+ export function _resetRateLimiter() {
52
+ pointLedger = [];
53
+ }
54
+
55
+ function fetchWithTimeout(url, options = {}, timeoutMs = 30_000) {
56
+ const controller = new AbortController();
57
+ const id = setTimeout(() => controller.abort(), timeoutMs);
58
+ return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(id));
59
+ }
60
+
61
+ async function withRetry(fn, maxAttempts = 3) {
62
+ let lastError;
63
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
64
+ try {
65
+ return await fn();
66
+ } catch (err) {
67
+ lastError = err;
68
+ const isRetryable =
69
+ err.name === "AbortError" ||
70
+ (err.message && (
71
+ err.message.includes("HTTP 429") ||
72
+ err.message.includes("HTTP 503") ||
73
+ err.message.includes("fetch failed")
74
+ ));
75
+ if (!isRetryable || attempt === maxAttempts) throw err;
76
+ await new Promise((r) => setTimeout(r, 1_000 * attempt));
77
+ }
78
+ }
79
+ throw lastError;
80
+ }
81
+
82
+ function morgenHeaders() {
83
+ const apiKey = process.env.MORGEN_API_KEY;
84
+ return {
85
+ Authorization: `ApiKey ${apiKey}`,
86
+ Accept: "application/json",
87
+ "Content-Type": "application/json",
88
+ };
89
+ }
90
+
91
+ function scrubKey(message) {
92
+ const key = process.env.MORGEN_API_KEY;
93
+ if (!message) return message;
94
+ let scrubbed = message.replace(/https?:\/\/[^\s)]+/g, "[redacted-url]");
95
+ if (key && key.length > 4) {
96
+ scrubbed = scrubbed.split(key).join("[redacted-key]");
97
+ }
98
+ return scrubbed;
99
+ }
100
+
101
+ export async function morgenFetch(path, { method = "GET", body, points = 1 } = {}) {
102
+ enforceRateLimit(points);
103
+
104
+ try {
105
+ return await withRetry(async () => {
106
+ const init = {
107
+ method,
108
+ headers: morgenHeaders(),
109
+ };
110
+ if (body !== undefined) {
111
+ init.body = JSON.stringify(body);
112
+ }
113
+
114
+ const res = await fetchWithTimeout(`${MORGEN_BASE}${path}`, init);
115
+
116
+ if (!res.ok) {
117
+ throw new Error(
118
+ `Morgen API error (HTTP ${res.status}). The request to ${path} was not successful.`
119
+ );
120
+ }
121
+
122
+ if (
123
+ res.status === 204 ||
124
+ res.status === 205 ||
125
+ res.headers.get("content-length") === "0"
126
+ ) {
127
+ return null;
128
+ }
129
+
130
+ return res.json();
131
+ });
132
+ } catch (err) {
133
+ const safe = scrubKey(err instanceof Error ? err.message : String(err));
134
+ throw new Error(safe || "Morgen API call failed");
135
+ }
136
+ }
@@ -0,0 +1,129 @@
1
+ // Pure helpers for shaping Morgen event/calendar API responses and
2
+ // building request bodies. Kept separate from tools-events.js to
3
+ // respect the 500-line-per-file project rule.
4
+ import { validateStringArray } from "./validation.js";
5
+
6
+ export const MAX_DESCRIPTION_LEN = 5000;
7
+ export const MAX_PARTICIPANTS = 100;
8
+ export const MAX_RECURRENCE_RULES = 20;
9
+
10
+ export function validateParticipantEmails(value, field = "participants") {
11
+ validateStringArray(value, field, MAX_PARTICIPANTS);
12
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
13
+ for (const entry of value) {
14
+ if (!emailPattern.test(entry)) {
15
+ throw new Error(`${field} entries must be valid email addresses`);
16
+ }
17
+ }
18
+ }
19
+
20
+ // Morgen expects participants as a keyed map:
21
+ // { <id>: { @type, email, roles, participationStatus } }.
22
+ // At creation we don't have server IDs yet, so key by email — the server
23
+ // assigns real IDs. Default each participant to attendee / needs-action.
24
+ export function toParticipantMap(emails = []) {
25
+ const map = {};
26
+ for (const email of emails) {
27
+ map[email] = {
28
+ "@type": "Participant",
29
+ email,
30
+ roles: { attendee: true },
31
+ participationStatus: "needs-action",
32
+ };
33
+ }
34
+ return map;
35
+ }
36
+
37
+ export function validateRecurrenceRules(value, field = "recurrence_rules") {
38
+ if (!Array.isArray(value)) {
39
+ throw new Error(`${field} must be an array of recurrence rule objects`);
40
+ }
41
+ if (value.length > MAX_RECURRENCE_RULES) {
42
+ throw new Error(`${field} exceeds maximum of ${MAX_RECURRENCE_RULES} rules`);
43
+ }
44
+ for (const rule of value) {
45
+ if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
46
+ throw new Error(
47
+ `${field} entries must be objects (e.g. { "@type": "RecurrenceRule", "frequency": "weekly", "interval": 1 })`
48
+ );
49
+ }
50
+ if (!rule.frequency || typeof rule.frequency !== "string") {
51
+ throw new Error(`${field} entries must include a "frequency" string`);
52
+ }
53
+ }
54
+ return value;
55
+ }
56
+
57
+ function deriveOrganizer(participants) {
58
+ if (!participants || typeof participants !== "object") return undefined;
59
+ for (const key of Object.keys(participants)) {
60
+ const p = participants[key];
61
+ if (p && p.roles && p.roles.owner === true) {
62
+ return p.email || p.name || key;
63
+ }
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ function mapParticipants(participants) {
69
+ if (!participants || typeof participants !== "object") return undefined;
70
+ const entries = Array.isArray(participants)
71
+ ? participants
72
+ : Object.values(participants);
73
+ return entries.map((p) => ({
74
+ email: p?.email,
75
+ name: p?.name,
76
+ participationStatus: p?.participationStatus,
77
+ isOrganizer: p?.roles?.owner === true,
78
+ isAttendee: p?.roles?.attendee === true,
79
+ }));
80
+ }
81
+
82
+ export function mapEvent(e) {
83
+ if (!e || typeof e !== "object") return e;
84
+ return {
85
+ id: e.id,
86
+ title: e.title,
87
+ start: e.start,
88
+ end: e.end,
89
+ calendarId: e.calendarId,
90
+ description:
91
+ typeof e.description === "string"
92
+ ? e.description.substring(0, MAX_DESCRIPTION_LEN)
93
+ : undefined,
94
+ location: e.location,
95
+ participants: mapParticipants(e.participants),
96
+ organizer: deriveOrganizer(e.participants),
97
+ recurrenceRules: e.recurrenceRules,
98
+ seriesId: e.seriesId,
99
+ };
100
+ }
101
+
102
+ export function mapCalendar(c) {
103
+ if (!c || typeof c !== "object") return c;
104
+ const rights = c.myRights || {};
105
+ const readOnly = rights.mayWriteAll === false && rights.mayReadItems === true;
106
+ return {
107
+ id: c.id,
108
+ name: c.name,
109
+ color: c.color,
110
+ accountId: c.accountId,
111
+ integrationId: c.integrationId,
112
+ sortOrder: c.sortOrder,
113
+ readOnly,
114
+ };
115
+ }
116
+
117
+ // Morgen wraps all responses in { data: { ... } }.
118
+ // See https://docs.morgen.so/calendars and https://docs.morgen.so/events
119
+ export function unwrapCalendars(data) {
120
+ return data?.data?.calendars ?? data?.calendars ?? [];
121
+ }
122
+
123
+ export function unwrapEvents(data) {
124
+ return data?.data?.events ?? data?.events ?? [];
125
+ }
126
+
127
+ export function unwrapEvent(data) {
128
+ return data?.data?.event ?? data?.event ?? data?.data ?? data;
129
+ }