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 +5 -0
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/bin/setup.js +96 -0
- package/package.json +44 -0
- package/src/client.js +136 -0
- package/src/events-shape.js +129 -0
- package/src/index.js +101 -0
- package/src/tools-events.js +460 -0
- package/src/tools-tasks.js +333 -0
- package/src/validation.js +47 -0
package/.env.example
ADDED
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
|
+
[](https://www.npmjs.com/package/morgen-mcp)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](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
|
+
}
|