mursa-mcp 0.4.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/package.json +45 -0
  4. package/server.js +432 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Murali Gurajapu
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,125 @@
1
+ # Mursa MCP
2
+
3
+ Connect Claude Code, Claude Desktop, Cursor, or any MCP-compatible client to
4
+ your Mursa tasks, calendar, goals, notes, habits, projects, and Gmail.
5
+
6
+ 📖 **Full guide with screenshots:** https://mursa.me/mcp
7
+
8
+ ---
9
+
10
+ ## 60-second install
11
+
12
+ ### 1. Get an API key
13
+
14
+ 1. Open [dashboard.mursa.me](https://dashboard.mursa.me) and sign in.
15
+ 2. Go to **Settings → API keys**.
16
+ 3. Click **New key** → pick a label, expiry, and scopes → **Create**.
17
+ 4. Copy the key (starts with `mursa_mcp_…`). You'll only see it once.
18
+
19
+ ### 2. Add to your client
20
+
21
+ #### Claude Code (`~/.claude.json`)
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "mursa": {
27
+ "command": "npx",
28
+ "args": ["-y", "mursa-mcp"],
29
+ "env": { "MURSA_API_KEY": "mursa_mcp_…" }
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ Restart Claude Code, run `/mcp` to confirm, call `whoami` to verify.
36
+
37
+ #### Claude Desktop
38
+
39
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
40
+ or `%APPDATA%/Claude/claude_desktop_config.json` (Windows) with the same block,
41
+ then restart the app.
42
+
43
+ #### Cursor
44
+
45
+ Settings → MCP → Add new MCP server, same JSON block.
46
+
47
+ ### 3. That's it
48
+
49
+ Your agent now has access to whatever scopes your key has.
50
+
51
+ ---
52
+
53
+ ## Tools (28)
54
+
55
+ | Group | Tools | Scope |
56
+ |---|---|---|
57
+ | Meta | `whoami` | (any) |
58
+ | Tasks (read) | `list_inbox`, `list_myday`, `list_schedule`, `search_tasks` | `tasks:read` |
59
+ | Tasks (write) | `create_task`, `update_task`, `complete_task`, `defer_task`, `schedule_task` | `tasks:write` |
60
+ | Calendar | `list_calendar`, `create_calendar_event` | `calendar:*` |
61
+ | Goals | `list_goals`, `create_goal`, `update_goal`, `delete_goal` | `goals:*` |
62
+ | Notes | `list_notes`, `search_notes`, `create_note`, `update_note` | `notes:*` |
63
+ | Habits | `list_habits` | `habits:read` |
64
+ | Projects | `list_projects` | `projects:read` |
65
+ | Email | `list_emails`, `get_email`, `get_attachment`, `search_emails` | `email:read` |
66
+ | Email | `send_email`, `reply_email` | `email:send` |
67
+
68
+ `*` as a scope grants everything.
69
+
70
+ ---
71
+
72
+ ## Security
73
+
74
+ - API keys are sha256-hashed at rest — the raw value is never stored.
75
+ - Per-key scopes + expiry. Revoke any key instantly from the dashboard.
76
+ - Per-action rate limits (60/min reads, 30/min writes, **5/min email send**).
77
+ - Every call audit-logged for 90 days (action, status, latency, IP — no payload).
78
+ - Email attachments capped at 3 MB each / 10 MB total per email.
79
+ - All traffic goes through `mursa.me/api/mcp`; the Supabase function is gated
80
+ by a shared proxy secret so direct hits return 403.
81
+
82
+ ---
83
+
84
+ ## Run from source (advanced)
85
+
86
+ ```bash
87
+ git clone https://github.com/Murali1889/Prod-Mursa.git
88
+ cd Prod-Mursa/mcp-servers/mursa
89
+ npm install
90
+ echo 'MURSA_API_KEY=mursa_mcp_…' > .env
91
+ ```
92
+
93
+ Then point your client at the absolute path:
94
+
95
+ ```json
96
+ {
97
+ "mcpServers": {
98
+ "mursa": {
99
+ "command": "node",
100
+ "args": ["/absolute/path/to/mcp-servers/mursa/server.js"]
101
+ }
102
+ }
103
+ }
104
+ ```
105
+
106
+ Override the endpoint for preview deployments or local dev:
107
+
108
+ ```
109
+ MURSA_API_URL=https://mursa-preview.vercel.app/api/mcp
110
+ ```
111
+
112
+ (Default is `https://mursa.me/api/mcp`.)
113
+
114
+ ---
115
+
116
+ ## Troubleshooting
117
+
118
+ | Symptom | Fix |
119
+ |---|---|
120
+ | `Invalid, expired, or revoked API key` | Mint a fresh key in the dashboard, swap `MURSA_API_KEY`, restart your client. |
121
+ | `This API key is missing the required scope: …` | Your key wasn't granted that scope at mint time. Revoke and mint a new one with the scope checked. |
122
+ | `Gmail is not connected. Connect Gmail in Mursa Settings first.` | Connect Gmail from the Mursa app first. |
123
+ | `Rate limit exceeded (5/min for send_email)` | Slow down — strict throttle on outbound mail. |
124
+
125
+ For server-side deployment (running your own Mursa instance), see `DEPLOY.md`.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "mursa-mcp",
3
+ "version": "0.4.0",
4
+ "description": "Mursa MCP server — connect Claude, Cursor, and other MCP clients to your Mursa tasks, calendar, goals, notes, habits, projects, and Gmail.",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "mursa-mcp": "server.js"
8
+ },
9
+ "type": "commonjs",
10
+ "files": [
11
+ "server.js",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "start": "node server.js"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "modelcontextprotocol",
24
+ "mursa",
25
+ "claude",
26
+ "claude-code",
27
+ "cursor",
28
+ "tasks",
29
+ "productivity"
30
+ ],
31
+ "license": "MIT",
32
+ "homepage": "https://mursa.me/mcp",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/Murali1889/MCP-mursa.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/Murali1889/MCP-mursa/issues"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.29.0",
42
+ "dotenv": "^16.4.5",
43
+ "zod": "^4.3.6"
44
+ }
45
+ }
package/server.js ADDED
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mursa MCP server (stdio).
4
+ *
5
+ * Architecture:
6
+ * Claude/Cursor/etc. ─stdio─> this Node process
7
+ * │
8
+ * │ fetch() with
9
+ * │ Authorization: Bearer <api key>
10
+ * â–¼
11
+ * Supabase Edge Function: mcp
12
+ * │ resolves key -> user_id + scopes
13
+ * â–¼
14
+ * Supabase Postgres
15
+ *
16
+ * The MCP server holds NO Supabase secrets. It only knows:
17
+ * - SUPABASE_URL (public)
18
+ * - MURSA_API_KEY (opaque per-user key with scopes + expiry)
19
+ *
20
+ * Every tool below just maps to one edge-function action.
21
+ */
22
+
23
+ require("dotenv").config({ path: require("path").join(__dirname, ".env") });
24
+
25
+ const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
26
+ const {
27
+ StdioServerTransport,
28
+ } = require("@modelcontextprotocol/sdk/server/stdio.js");
29
+ const { z } = require("zod");
30
+
31
+ // Default to the public Mursa proxy. Override only if you're self-hosting or
32
+ // pointing at a preview deployment.
33
+ const MURSA_API_URL = process.env.MURSA_API_URL || "https://mursa.me/api/mcp";
34
+ const MURSA_API_KEY = process.env.MURSA_API_KEY;
35
+
36
+ if (!MURSA_API_KEY) {
37
+ console.error("[mursa-mcp] MURSA_API_KEY is not set in .env");
38
+ process.exit(1);
39
+ }
40
+
41
+ const ENDPOINT = MURSA_API_URL.replace(/\/$/, "");
42
+
43
+ // ───────────────────────────── call() ─────────────────────────────
44
+
45
+ async function call(action, args = {}) {
46
+ let res;
47
+ try {
48
+ res = await fetch(ENDPOINT, {
49
+ method: "POST",
50
+ headers: {
51
+ Authorization: `Bearer ${MURSA_API_KEY}`,
52
+ "Content-Type": "application/json",
53
+ },
54
+ body: JSON.stringify({ action, args }),
55
+ });
56
+ } catch (e) {
57
+ throw new Error(`Network error calling ${ENDPOINT}: ${e.message}`);
58
+ }
59
+
60
+ let body;
61
+ try {
62
+ body = await res.json();
63
+ } catch {
64
+ throw new Error(`Non-JSON response (HTTP ${res.status})`);
65
+ }
66
+
67
+ if (!res.ok || body.error) {
68
+ throw new Error(body.error || `HTTP ${res.status}`);
69
+ }
70
+ return body.data;
71
+ }
72
+
73
+ function jsonContent(payload) {
74
+ return {
75
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
76
+ };
77
+ }
78
+
79
+ function errorContent(err) {
80
+ return {
81
+ isError: true,
82
+ content: [{ type: "text", text: `Error: ${err.message || String(err)}` }],
83
+ };
84
+ }
85
+
86
+ const server = new McpServer({ name: "mursa", version: "0.3.0" });
87
+
88
+ function tool(name, description, schema, action) {
89
+ server.tool(name, description, schema, async (args) => {
90
+ try {
91
+ const data = await call(action, args ?? {});
92
+ return jsonContent(data);
93
+ } catch (err) {
94
+ return errorContent(err);
95
+ }
96
+ });
97
+ }
98
+
99
+ // ───────────────────────────── tools ─────────────────────────────
100
+
101
+ // Meta
102
+ tool("whoami", "Show which Mursa user this API key belongs to.", {}, "whoami");
103
+
104
+ // Tasks: read
105
+ tool(
106
+ "list_inbox",
107
+ "List Inbox tasks (unscheduled, personal, not completed by default).",
108
+ {
109
+ status: z.string().optional(),
110
+ limit: z.number().int().positive().max(200).optional(),
111
+ includeScheduled: z.boolean().optional(),
112
+ },
113
+ "list_inbox"
114
+ );
115
+
116
+ tool(
117
+ "list_myday",
118
+ "List tasks scheduled for a specific day (default today). Date: YYYY-MM-DD.",
119
+ { date: z.string().optional() },
120
+ "list_myday"
121
+ );
122
+
123
+ tool(
124
+ "list_schedule",
125
+ "List all scheduled tasks between startDate and endDate inclusive. Dates: YYYY-MM-DD.",
126
+ { startDate: z.string(), endDate: z.string() },
127
+ "list_schedule"
128
+ );
129
+
130
+ tool(
131
+ "search_tasks",
132
+ "Search task titles by ILIKE match. Returns most recently updated first.",
133
+ { query: z.string().min(1), limit: z.number().int().positive().max(100).optional() },
134
+ "search_tasks"
135
+ );
136
+
137
+ // Calendar (= time-blocked scheduled tasks)
138
+ tool(
139
+ "list_calendar",
140
+ "List calendar events (scheduled tasks with start_time) between two dates. Dates: YYYY-MM-DD.",
141
+ { startDate: z.string(), endDate: z.string() },
142
+ "list_calendar"
143
+ );
144
+
145
+ tool(
146
+ "create_calendar_event",
147
+ "Create a calendar event (a task with scheduled_date + start_time/end_time, task_type='meeting' by default).",
148
+ {
149
+ title: z.string().min(1),
150
+ scheduled_date: z.string(),
151
+ start_time: z.string().optional(),
152
+ end_time: z.string().optional(),
153
+ duration_minutes: z.number().int().positive().optional(),
154
+ description: z.string().optional(),
155
+ why: z.string().optional(),
156
+ task_type: z.enum(["deep", "shallow", "admin", "meeting"]).optional(),
157
+ priority: z.enum(["low", "medium", "high", "urgent"]).optional(),
158
+ },
159
+ "create_calendar_event"
160
+ );
161
+
162
+ // Tasks: write
163
+ tool(
164
+ "create_task",
165
+ "Create a task. With scheduled_date -> MyDay; without -> Inbox.",
166
+ {
167
+ title: z.string().min(1),
168
+ description: z.string().optional(),
169
+ priority: z.enum(["low", "medium", "high", "urgent"]).optional(),
170
+ due_date: z.string().optional(),
171
+ labels: z.array(z.string()).optional(),
172
+ category: z.string().optional(),
173
+ estimated_hours: z.number().nonnegative().optional(),
174
+ goal_id: z.string().optional(),
175
+ project_id: z.string().optional(),
176
+ scheduled_date: z.string().optional(),
177
+ start_time: z.string().optional(),
178
+ end_time: z.string().optional(),
179
+ duration_minutes: z.number().int().positive().optional(),
180
+ task_type: z.enum(["deep", "shallow", "admin", "meeting"]).optional(),
181
+ why: z.string().optional(),
182
+ },
183
+ "create_task"
184
+ );
185
+
186
+ tool(
187
+ "update_task",
188
+ "Patch any subset of editable fields on a task.",
189
+ {
190
+ task_id: z.string(),
191
+ title: z.string().optional(),
192
+ description: z.string().optional(),
193
+ status: z.string().optional(),
194
+ priority: z.string().optional(),
195
+ due_date: z.string().nullable().optional(),
196
+ labels: z.array(z.string()).optional(),
197
+ category: z.string().optional(),
198
+ estimated_hours: z.number().nonnegative().optional(),
199
+ goal_id: z.string().nullable().optional(),
200
+ project_id: z.string().nullable().optional(),
201
+ scheduled_date: z.string().nullable().optional(),
202
+ start_time: z.string().nullable().optional(),
203
+ end_time: z.string().nullable().optional(),
204
+ duration_minutes: z.number().int().positive().optional(),
205
+ task_type: z.string().optional(),
206
+ why: z.string().optional(),
207
+ sort_order: z.number().int().optional(),
208
+ },
209
+ "update_task"
210
+ );
211
+
212
+ tool(
213
+ "complete_task",
214
+ "Mark a task as completed.",
215
+ { task_id: z.string() },
216
+ "complete_task"
217
+ );
218
+
219
+ tool(
220
+ "defer_task",
221
+ "Set a task's due_date to a new date (does not change scheduled_date).",
222
+ { task_id: z.string(), new_date: z.string() },
223
+ "defer_task"
224
+ );
225
+
226
+ tool(
227
+ "schedule_task",
228
+ "Move a task onto a specific date (MyDay). Optionally set start/end time and duration.",
229
+ {
230
+ task_id: z.string(),
231
+ scheduled_date: z.string(),
232
+ start_time: z.string().optional(),
233
+ end_time: z.string().optional(),
234
+ duration_minutes: z.number().int().positive().optional(),
235
+ task_type: z.enum(["deep", "shallow", "admin", "meeting"]).optional(),
236
+ },
237
+ "schedule_task"
238
+ );
239
+
240
+ // Goals
241
+ tool(
242
+ "list_goals",
243
+ "List your goals, optionally filtered by status ('active', 'completed', ...).",
244
+ { status: z.string().optional() },
245
+ "list_goals"
246
+ );
247
+
248
+ tool(
249
+ "create_goal",
250
+ "Create a goal.",
251
+ {
252
+ title: z.string().min(1),
253
+ description: z.string().optional(),
254
+ color: z.string().optional(),
255
+ target_date: z.string().optional(),
256
+ timeline_days: z.number().int().positive().optional(),
257
+ project_id: z.string().optional(),
258
+ status: z.string().optional(),
259
+ priority: z.number().int().min(1).max(5).optional(),
260
+ tags: z.array(z.string()).optional(),
261
+ horizon: z.string().optional(),
262
+ area: z.string().optional(),
263
+ },
264
+ "create_goal"
265
+ );
266
+
267
+ tool(
268
+ "update_goal",
269
+ "Patch a goal.",
270
+ {
271
+ goal_id: z.string(),
272
+ title: z.string().optional(),
273
+ description: z.string().optional(),
274
+ color: z.string().optional(),
275
+ progress: z.number().min(0).max(100).optional(),
276
+ target_date: z.string().nullable().optional(),
277
+ timeline_days: z.number().int().positive().optional(),
278
+ status: z.string().optional(),
279
+ priority: z.number().int().min(1).max(5).optional(),
280
+ is_archived: z.boolean().optional(),
281
+ tags: z.array(z.string()).optional(),
282
+ sort_order: z.number().int().optional(),
283
+ horizon: z.string().nullable().optional(),
284
+ area: z.string().nullable().optional(),
285
+ },
286
+ "update_goal"
287
+ );
288
+
289
+ tool(
290
+ "delete_goal",
291
+ "Delete a goal. Tasks that reference it are unlinked first (goal_id set to null).",
292
+ { goal_id: z.string() },
293
+ "delete_goal"
294
+ );
295
+
296
+ // Notes
297
+ tool(
298
+ "list_notes",
299
+ "List notes, pinned first, then most recently updated.",
300
+ { limit: z.number().int().positive().max(200).optional() },
301
+ "list_notes"
302
+ );
303
+
304
+ tool(
305
+ "search_notes",
306
+ "Search note titles and content by ILIKE.",
307
+ { query: z.string().min(1), limit: z.number().int().positive().max(100).optional() },
308
+ "search_notes"
309
+ );
310
+
311
+ tool(
312
+ "create_note",
313
+ "Create a note.",
314
+ {
315
+ title: z.string().optional(),
316
+ content: z.string().optional(),
317
+ tags: z.array(z.string()).optional(),
318
+ color: z.string().optional(),
319
+ },
320
+ "create_note"
321
+ );
322
+
323
+ tool(
324
+ "update_note",
325
+ "Patch a note.",
326
+ {
327
+ note_id: z.string(),
328
+ title: z.string().optional(),
329
+ content: z.string().optional(),
330
+ tags: z.array(z.string()).optional(),
331
+ color: z.string().optional(),
332
+ isPinned: z.boolean().optional(),
333
+ },
334
+ "update_note"
335
+ );
336
+
337
+ // Habits & projects (read-only for v1)
338
+ tool("list_habits", "List your active (non-archived) habits.", {}, "list_habits");
339
+ tool("list_projects", "List your projects, most recently updated first.", {}, "list_projects");
340
+
341
+ // ───────────────────────────── EMAIL (Gmail) ─────────────────────────────
342
+
343
+ const sendAttachmentSchema = z.object({
344
+ filename: z.string().min(1),
345
+ mimeType: z.string().optional(),
346
+ contentBase64: z.string().min(1),
347
+ });
348
+
349
+ tool(
350
+ "list_emails",
351
+ "List Gmail threads in a label (default INBOX). Returns thread previews (subject/from/snippet/labels/unread).",
352
+ {
353
+ maxResults: z.number().int().min(1).max(50).optional(),
354
+ pageToken: z.string().optional(),
355
+ q: z.string().optional(),
356
+ label: z.string().optional(),
357
+ },
358
+ "list_emails"
359
+ );
360
+
361
+ tool(
362
+ "get_email",
363
+ "Get the full content of a thread or single message. Returns body text + HTML + attachment metadata (use get_attachment to download).",
364
+ {
365
+ threadId: z.string().optional(),
366
+ messageId: z.string().optional(),
367
+ },
368
+ "get_email"
369
+ );
370
+
371
+ tool(
372
+ "get_attachment",
373
+ "Download a single attachment from an email. Returns { size, contentBase64 } — decode the base64 to get the bytes.",
374
+ {
375
+ messageId: z.string(),
376
+ attachmentId: z.string(),
377
+ },
378
+ "get_attachment"
379
+ );
380
+
381
+ tool(
382
+ "search_emails",
383
+ "Search Gmail using Gmail's query syntax (e.g. 'from:alice has:attachment newer_than:7d'). Returns message ids; use get_email to fetch.",
384
+ {
385
+ query: z.string().min(1),
386
+ maxResults: z.number().int().min(1).max(50).optional(),
387
+ },
388
+ "search_emails"
389
+ );
390
+
391
+ tool(
392
+ "send_email",
393
+ "Send a new email. Attachments are base64-encoded; ≤3MB each, ≤10MB total. Rate-limited to 5/min.",
394
+ {
395
+ to: z.union([z.string(), z.array(z.string())]),
396
+ cc: z.union([z.string(), z.array(z.string())]).optional(),
397
+ bcc: z.union([z.string(), z.array(z.string())]).optional(),
398
+ subject: z.string().min(1),
399
+ bodyText: z.string().optional(),
400
+ bodyHtml: z.string().optional(),
401
+ attachments: z.array(sendAttachmentSchema).optional(),
402
+ },
403
+ "send_email"
404
+ );
405
+
406
+ tool(
407
+ "reply_email",
408
+ "Reply to an existing thread. Subject and recipient default to the last message's. Rate-limited to 5/min.",
409
+ {
410
+ threadId: z.string(),
411
+ to: z.union([z.string(), z.array(z.string())]).optional(),
412
+ cc: z.union([z.string(), z.array(z.string())]).optional(),
413
+ bcc: z.union([z.string(), z.array(z.string())]).optional(),
414
+ subject: z.string().optional(),
415
+ bodyText: z.string().optional(),
416
+ bodyHtml: z.string().optional(),
417
+ attachments: z.array(sendAttachmentSchema).optional(),
418
+ },
419
+ "reply_email"
420
+ );
421
+
422
+ // ───────────────────────────── main ─────────────────────────────
423
+
424
+ async function main() {
425
+ const transport = new StdioServerTransport();
426
+ await server.connect(transport);
427
+ }
428
+
429
+ main().catch((err) => {
430
+ console.error("[mursa-mcp] fatal:", err);
431
+ process.exit(1);
432
+ });