planko-mcp-server 0.2.1 → 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.
package/README.md CHANGED
@@ -4,10 +4,13 @@ MCP server for syncing [Planko](https://planko.io) tasks with local Markdown fil
4
4
 
5
5
  ## Features
6
6
 
7
- - **3 tools**: setup, sync preview, sync
7
+ - **14 tools**: 3 folder-sync tools + 7 standalone task/note CRUD tools + 4 standalone read (list/view) tools
8
+ - **Type-aware sync**: each folder syncs either **tasks** (type=1) or **notes** (type=2), chosen at setup
8
9
  - **Multi-folder**: sync multiple projects to different local folders
9
10
  - **Bidirectional sync**: pull remote changes, push local changes
10
11
  - **Delete sync**: deleted local files remove tasks on server; deleted tasks remove local files
12
+ - **Standalone CRUD**: create/edit/complete/delete tasks and notes directly, no folder setup required
13
+ - **Standalone read**: list and view tasks/notes with rich filters, no folder setup required
11
14
  - **User-scoped API key**: one key works across all your projects
12
15
 
13
16
  ## Getting Started
@@ -64,10 +67,15 @@ Restart Claude Code, Cursor, or whichever MCP client you use so it picks up the
64
67
  Ask your AI agent to run:
65
68
 
66
69
  ```
67
- planko_setup(projectName: "My Project", folderPath: "/path/to/folder", email: "you@email.com")
70
+ planko_setup(projectName: "My Project", folderPath: "/path/to/folder", email: "you@email.com", syncType: "tasks")
68
71
  ```
69
72
 
70
- This maps a Planko project to a local folder. You can set up multiple projects pointing to different folders.
73
+ This maps a Planko project to a local folder. `syncType` is **required** and must be `"tasks"` or `"notes"`:
74
+
75
+ - `syncType: "tasks"` — the folder pulls/pushes only **tasks** (type=1). New local `.md` files are created as tasks.
76
+ - `syncType: "notes"` — the folder pulls/pushes only **notes** (type=2). New local `.md` files are created as notes.
77
+
78
+ A folder is bound to one type for its whole lifetime. To sync both tasks and notes for the same project, set up two folders. You can set up multiple projects pointing to different folders.
71
79
 
72
80
  ### Step 5 — Sync
73
81
 
@@ -79,12 +87,108 @@ Tasks are pulled as `.md` files into your folder. Local changes are pushed back
79
87
 
80
88
  ## Tools
81
89
 
90
+ The server exposes 14 tools in three groups: **folder-sync** tools (bind a project to a local folder), **standalone CRUD** tools (create/edit/complete/delete directly via your API key, no folder needed), and **standalone read** tools (list/view directly via your API key, no folder needed).
91
+
92
+ ### Folder-sync tools
93
+
82
94
  | Tool | Description | Parameters |
83
95
  |---|---|---|
84
- | `planko_setup` | Set up sync between a project and a local folder | `projectName`, `folderPath`, `email` |
96
+ | `planko_setup` | Set up sync between a project and a local folder | `projectName`, `folderPath`, `email`, `syncType` (`tasks`\|`notes`) |
85
97
  | `planko_sync_preview` | Preview what would be synced (read-only) | `projectName` (optional) |
86
98
  | `planko_sync` | Execute bidirectional sync with delete support | `projectName` (optional) |
87
99
 
100
+ ### Standalone CRUD tools
101
+
102
+ These work with the **same API key** and require **no folder setup**. Notes are Planko tasks with `type=2`; tasks are `type=1`. They live in the same collection, so edit/delete work on both. `create_*` tools accept all task/note properties as optional params (only `name` is required); the Markdown `description` is converted to Planko's rich-text (BlockNote) format automatically.
103
+
104
+ | Tool | Description | Key parameters |
105
+ |---|---|---|
106
+ | `planko_create_task` | Create a task (type=1) | `name` (required), `projectName` (optional), plus any properties below |
107
+ | `planko_create_note` | Create a note (type=2) | `name` (required), `projectName` (optional), plus any properties below |
108
+ | `planko_edit_task` | Edit an existing task by id | `taskId` (required), plus any properties to change |
109
+ | `planko_edit_note` | Edit an existing note by id (shares the task edit endpoint) | `taskId` (required), plus any properties to change |
110
+ | `planko_complete_task` | Mark a task complete (status=2) | `taskId` (required) |
111
+ | `planko_delete_task` | Delete a task by id | `taskId` (required) |
112
+ | `planko_delete_note` | Delete a note by id (shares the task delete endpoint) | `taskId` (required) |
113
+
114
+ **Optional properties** accepted by the create/edit tools (all optional; omit to leave unchanged):
115
+
116
+ | Property | Type | Notes |
117
+ |---|---|---|
118
+ | `description` | Markdown string | Converted to BlockNote JSON before saving |
119
+ | `dueDate` | ISO 8601 string | |
120
+ | `datePlain` | `YYYY-MM-DD` | |
121
+ | `time` / `endTime` | `HH:MM` | Start / end time |
122
+ | `alert` | boolean | Whether a reminder is enabled |
123
+ | `alertLeadTime` | `null`\|`ontime`\|`15min`\|`30min`\|`1hour`\|`1day` | Reminder lead time |
124
+ | `priority` | `1`\|`2`\|`3` | |
125
+ | `repeat` | `null`\|`daily`\|`workdays`\|`weekly`\|`biweekly`\|`monthly`\|`custom_weekly`\|`yearly` | Recurrence rule |
126
+ | `repeatDate` | ISO 8601 string | Recurrence anchor/end |
127
+ | `selectedWeekdays` | array of `0`–`6` | For `custom_weekly` (0=Sunday) |
128
+ | `parentId` | ObjectId | Parent task, for subtasks |
129
+ | `tags` | array of ObjectId | Tag ids |
130
+ | `kanbanColumnId` / `boardId` | ObjectId | Kanban placement |
131
+ | `type` | `1`\|`2` | (edit tools only) switch task↔note |
132
+ | `projectId` | ObjectId | (edit tools only) move to another project |
133
+
134
+ Notes:
135
+
136
+ - On create, `projectName` is resolved to a project id via your accessible projects (not the local folder config). If omitted, the backend applies your default project.
137
+ - `complete` sets status=2 and, for a recurring task, stops the series and completes the current occurrence.
138
+ - Reminders, recurrence, positions, kanban and timezone handling are all applied server-side (the tools reuse Planko's own task logic).
139
+
140
+ #### Examples
141
+
142
+ ```
143
+ planko_create_task(name: "Ship v2", projectName: "Work", dueDate: "2026-08-01", priority: 1)
144
+ planko_create_note(name: "Meeting notes", description: "# Sync\n- point A\n- point B")
145
+ planko_edit_task(taskId: "665f...c0", name: "Ship v2.1", alertLeadTime: "1hour")
146
+ planko_complete_task(taskId: "665f...c0")
147
+ planko_delete_note(taskId: "665f...aa")
148
+ ```
149
+
150
+ ### Standalone read tools
151
+
152
+ These also work with the **same API key** and require **no folder setup**. `list_*` returns a concise, readable summary (one line per item plus a `total`/page footer) — never a raw JSON dump. `view_*` returns full detail with the `description` converted from BlockNote to **Markdown** for display.
153
+
154
+ | Tool | Description | Key parameters |
155
+ |---|---|---|
156
+ | `planko_list_tasks` | List your tasks (type=1) | all filters below |
157
+ | `planko_list_notes` | List your notes (type=2) | filter subset: `showCompleted`, `projectName`/`projectId`, `tags`, `search`, `sortBy`, `limit`, `page` |
158
+ | `planko_view_task` | View one task by id | `taskId` (required) |
159
+ | `planko_view_note` | View one note by id (shares the view endpoint) | `taskId` (required) |
160
+
161
+ **Scope.** Without `projectId`/`projectName`, a list is scoped to **your own** items. With a project (resolved from `projectName` via your accessible projects), it includes that project's items **including workspace-shared** ones from other members. `view_*` returns the item if you own it or can access its project, otherwise it errors (404 if missing, 403 if not visible). `view_task`/`view_note` do not filter by type — either id resolves; the `task`/`note` label is informational.
162
+
163
+ **List filters** (`planko_list_tasks` accepts all; `planko_list_notes` accepts the applicable subset):
164
+
165
+ | Filter | Type | Notes |
166
+ |---|---|---|
167
+ | `status` | `1` \| `2` | 1=open, 2=complete |
168
+ | `showCompleted` | boolean | When false/omitted and no explicit `status`, completed items are excluded |
169
+ | `priority` | `1` \| `2` \| `3` | (tasks only) |
170
+ | `projectName` | string | Resolved to a project id via your accessible projects |
171
+ | `projectId` | ObjectId | Alternative to `projectName` |
172
+ | `parentId` | ObjectId | (tasks only) list subtasks of this parent |
173
+ | `tags` | array of ObjectId | AND semantics — item must have all tags |
174
+ | `search` | string | Case-insensitive match on the name |
175
+ | `dueDateFrom` / `dueDateTo` | `YYYY-MM-DD` | (tasks only) inclusive due-date bounds |
176
+ | `sortBy` | `field:asc` \| `field:desc` | Fields: `dueDate`, `createdAt`, `updatedAt`, `priority`, `position`, `name`; default `updatedAt:desc` |
177
+ | `limit` | int | Default 50, max 200 |
178
+ | `page` | int | Default 1 |
179
+
180
+ Notes-specific behavior: deleted notes and recurring-copy notes are excluded server-side. For tasks, recurring occurrences appear as separate dated items.
181
+
182
+ #### Examples
183
+
184
+ ```
185
+ planko_list_tasks(status: 1, priority: 1, dueDateFrom: "2026-07-20", dueDateTo: "2026-07-26", sortBy: "dueDate:asc")
186
+ planko_list_tasks(projectName: "Work", search: "invoice", limit: 20)
187
+ planko_list_notes(search: "meeting", showCompleted: false)
188
+ planko_view_task(taskId: "665f...c0")
189
+ planko_view_note(taskId: "665f...aa")
190
+ ```
191
+
88
192
  ### Sync preview
89
193
 
90
194
  ```
@@ -112,10 +216,14 @@ Sync order: delete, pull, push. Local changes overwrite remote on conflict.
112
216
 
113
217
  ## How it works
114
218
 
115
- - Each Planko task maps to a local `.md` file (task name becomes filename)
219
+ - Each Planko task/note maps to a local `.md` file (task name becomes filename)
220
+ - Each folder is **type-bound**: `planko_setup` records `type` (1=tasks, 2=notes) on the folder's sync state and in the global config, and sync only pulls/pushes that type. New local `.md` files are created with the folder's type.
221
+ - The chosen `type` is sent to the backend on every `status`/`pull` (`&type=`) and `push` (body `type`) call.
222
+ - Backward-compatible: a config entry created before this feature has no `type`, so its folder keeps the legacy type-agnostic behavior (syncs all task types).
116
223
  - Task descriptions are converted between BlockNote and Markdown automatically
117
- - Sync state is tracked in `.planko-mcp-sync.json` per folder
118
- - Global config is stored at `~/.planko-mcp/config.json`
224
+ - Sync state is tracked in `.planko-mcp-sync.json` per folder (includes `syncType` when set)
225
+ - Global config is stored at `~/.planko-mcp/config.json` (each project entry stores `projectId`, `folder`, `email`, `type`)
226
+ - The standalone CRUD tools bypass folders entirely and call user-scoped endpoints directly with your API key
119
227
  - API keys are never written to any file
120
228
 
121
229
  ## License
package/index.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  blockNoteToMarkdown,
25
25
  markdownToBlockNote,
26
26
  descriptionToMarkdown,
27
+ markdownToDescription,
27
28
  } from './src/converters.js';
28
29
  import {
29
30
  readConfig,
@@ -71,9 +72,10 @@ function toolError(text) {
71
72
  async function computeSyncDiff(projectId, folder, sync) {
72
73
  const localFiles = listLocalFiles(folder);
73
74
  const { byFileName, byId } = buildIndexes(sync.tasks);
75
+ const syncType = sync.syncType;
74
76
 
75
77
  // Get ALL tasks from server (no mcpLastSyncDate filter) to detect deletions
76
- const statusData = await api.status(projectId);
78
+ const statusData = await api.status(projectId, null, syncType);
77
79
  const remoteTasks = statusData.tasks || [];
78
80
  const remoteIdSet = new Set(remoteTasks.map((t) => t._id.toString()));
79
81
 
@@ -169,10 +171,11 @@ async function computeSyncDiff(projectId, folder, sync) {
169
171
  async function executeSync(projectId, folder, sync) {
170
172
  const localFiles = listLocalFiles(folder);
171
173
  const prePullSyncDate = sync.mcpLastSyncDate;
174
+ const syncType = sync.syncType;
172
175
  const results = [];
173
176
 
174
177
  // Get ALL tasks to detect deletions
175
- const allStatusData = await api.status(projectId);
178
+ const allStatusData = await api.status(projectId, null, syncType);
176
179
  const allRemoteTasks = allStatusData.tasks || [];
177
180
  const remoteIdSet = new Set(allRemoteTasks.map((t) => t._id.toString()));
178
181
 
@@ -214,7 +217,7 @@ async function executeSync(projectId, folder, sync) {
214
217
  }
215
218
 
216
219
  if (toDeleteRemote.length > 0) {
217
- const deleteResponse = await api.push(projectId, toDeleteRemote);
220
+ const deleteResponse = await api.push(projectId, toDeleteRemote, syncType);
218
221
  const deletedRemote = (deleteResponse.tasks || []).filter((t) => t.deleted);
219
222
  // Remove deleted tasks from sync state
220
223
  const deletedIds = new Set(deletedRemote.map((t) => t._id.toString()));
@@ -223,7 +226,7 @@ async function executeSync(projectId, folder, sync) {
223
226
  }
224
227
 
225
228
  // --- PULL phase ---
226
- const pullData = await api.pull(projectId, prePullSyncDate);
229
+ const pullData = await api.pull(projectId, prePullSyncDate, syncType);
227
230
  const pulledTasks = pullData.tasks || [];
228
231
 
229
232
  const { byId } = buildIndexes(sync.tasks);
@@ -308,7 +311,7 @@ async function executeSync(projectId, folder, sync) {
308
311
  });
309
312
  }
310
313
 
311
- const pushResponse = await api.push(projectId, pushItems);
314
+ const pushResponse = await api.push(projectId, pushItems, syncType);
312
315
  const responseTasks = (pushResponse.tasks || []).filter((t) => !t.deleted);
313
316
 
314
317
  const { byId: pushIdIdx, byFileName: pushFnIdx } = buildIndexes(sync.tasks);
@@ -363,13 +366,16 @@ const server = new McpServer({
363
366
  // ---- planko_setup ----
364
367
  server.tool(
365
368
  'planko_setup',
366
- 'Set up sync between a Planko project and a local folder. Supports multiple project-folder mappings. The user must provide the project name, local folder path, and email.',
369
+ 'Set up sync between a Planko project and a local folder. Supports multiple project-folder mappings. The user must provide the project name, local folder path, email, and whether the folder syncs tasks or notes.',
367
370
  {
368
371
  projectName: z.string().describe('Name of the Planko project to sync'),
369
372
  folderPath: z.string().describe('Absolute path to the local folder for .md task files'),
370
373
  email: z.string().email().describe('User email for task attribution'),
374
+ syncType: z
375
+ .enum(['tasks', 'notes'])
376
+ .describe("Whether this folder syncs 'tasks' (type=1) or 'notes' (type=2)"),
371
377
  },
372
- async ({ projectName, folderPath, email }) => {
378
+ async ({ projectName, folderPath, email, syncType }) => {
373
379
  try {
374
380
  // List user's projects to find the matching one
375
381
  const { projects } = await api.projects();
@@ -390,6 +396,8 @@ server.tool(
390
396
  mkdirSync(folderPath, { recursive: true });
391
397
  }
392
398
 
399
+ const type = syncType === 'notes' ? 2 : 1;
400
+
393
401
  // Save to global config
394
402
  const config = readConfig();
395
403
  config.projects = config.projects || {};
@@ -398,20 +406,24 @@ server.tool(
398
406
  folder: folderPath,
399
407
  email,
400
408
  isWorkspace: match.isWorkspace,
409
+ type,
401
410
  };
402
411
  writeConfig(config);
403
412
 
404
413
  // Initialize sync state for this folder
405
- const syncState = createSyncState(match._id, match.name);
414
+ const syncState = createSyncState(match._id, match.name, type);
406
415
  writeSyncState(folderPath, syncState);
407
416
 
417
+ const typeLabel = type === 2 ? 'Notes' : 'Tasks';
418
+
408
419
  return toolOk(
409
420
  `Setup complete for project "${match.name}".\n` +
410
421
  ` Project ID: ${match._id}\n` +
411
422
  ` Folder: ${folderPath}\n` +
412
423
  ` Email: ${email}\n` +
424
+ ` Syncs: ${typeLabel} (type=${type})\n` +
413
425
  ` Workspace: ${match.isWorkspace ? 'Yes' : 'No (personal)'}\n\n` +
414
- `Run planko_sync to pull tasks into this folder.`
426
+ `Run planko_sync to pull ${typeLabel.toLowerCase()} into this folder.`
415
427
  );
416
428
  } catch (err) {
417
429
  return toolError(`Setup failed: ${err.message}`);
@@ -540,7 +552,8 @@ server.tool(
540
552
  for (const target of targets) {
541
553
  let sync = readSyncState(target.folder);
542
554
  if (!sync) {
543
- sync = createSyncState(target.projectId, target.name);
555
+ // target.type is undefined for pre-feature config entries → legacy type-agnostic sync.
556
+ sync = createSyncState(target.projectId, target.name, target.type);
544
557
  }
545
558
 
546
559
  allResults.push(`--- ${target.name} (${target.folder}) ---`);
@@ -561,6 +574,505 @@ server.tool(
561
574
  }
562
575
  );
563
576
 
577
+ // --- Standalone CRUD tools (Part B) ---
578
+ // These operate directly via the user-scoped API key and do NOT require any
579
+ // folder to be configured with planko_setup. Notes are Task docs with type=2;
580
+ // tasks are type=1. Edit/Delete work on both; Complete is task-oriented.
581
+
582
+ const objectId = z
583
+ .string()
584
+ .regex(/^[a-fA-F\d]{24}$/, 'Must be a 24-character hex ObjectId');
585
+
586
+ const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
587
+
588
+ // Shared editable task/note properties (all optional). `description` is Markdown
589
+ // here and is converted to BlockNote JSON before being sent to the backend.
590
+ const taskProps = {
591
+ description: z
592
+ .string()
593
+ .optional()
594
+ .describe('Body as Markdown (converted to BlockNote JSON before saving)'),
595
+ dueDate: z.string().optional().describe('Due date (ISO 8601 string)'),
596
+ datePlain: z
597
+ .string()
598
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD')
599
+ .optional()
600
+ .describe('Plain date, YYYY-MM-DD'),
601
+ time: z.string().regex(timeRegex, 'Must be HH:MM').optional().describe('Start time, HH:MM'),
602
+ endTime: z.string().regex(timeRegex, 'Must be HH:MM').optional().describe('End time, HH:MM'),
603
+ alert: z.boolean().optional().describe('Whether an alert/reminder is enabled'),
604
+ alertLeadTime: z
605
+ .enum(['ontime', '15min', '30min', '1hour', '1day'])
606
+ .nullable()
607
+ .optional()
608
+ .describe('Reminder lead time (null or one of ontime/15min/30min/1hour/1day)'),
609
+ priority: z
610
+ .union([z.literal(1), z.literal(2), z.literal(3)])
611
+ .optional()
612
+ .describe('Priority: 1, 2, or 3'),
613
+ repeat: z
614
+ .enum(['daily', 'workdays', 'weekly', 'biweekly', 'monthly', 'custom_weekly', 'yearly'])
615
+ .nullable()
616
+ .optional()
617
+ .describe('Recurrence rule (null or one of the recurrence keywords)'),
618
+ repeatDate: z.string().optional().describe('Recurrence end/anchor date (ISO 8601 string)'),
619
+ selectedWeekdays: z
620
+ .array(z.number().int().min(0).max(6))
621
+ .optional()
622
+ .describe('Weekdays for custom_weekly repeat (0=Sunday..6=Saturday)'),
623
+ parentId: objectId.optional().describe('Parent task id (for subtasks)'),
624
+ tags: z.array(objectId).optional().describe('Array of tag ids'),
625
+ kanbanColumnId: objectId.optional().describe('Kanban column id'),
626
+ boardId: objectId.optional().describe('Board id'),
627
+ };
628
+
629
+ /**
630
+ * Build a request body from tool params: drop undefined values and convert the
631
+ * Markdown `description` into BlockNote JSON.
632
+ */
633
+ function buildTaskBody(params) {
634
+ const body = {};
635
+ for (const [key, value] of Object.entries(params)) {
636
+ if (value === undefined) continue;
637
+ body[key] = value;
638
+ }
639
+ if ('description' in body) {
640
+ body.description = markdownToDescription(body.description);
641
+ }
642
+ return body;
643
+ }
644
+
645
+ /**
646
+ * Resolve a project name to a project id via the user's accessible projects
647
+ * (NOT the local folder config), so create tools work without any setup.
648
+ */
649
+ async function resolveProjectId(projectName) {
650
+ const { projects } = await api.projects();
651
+ const match = projects.find(
652
+ (p) => p.name.toLowerCase() === projectName.toLowerCase()
653
+ );
654
+ if (!match) {
655
+ const available = projects.map((p) => ` - ${p.name}`).join('\n');
656
+ throw new Error(
657
+ `Project "${projectName}" not found.\n\nAvailable projects:\n${available}`
658
+ );
659
+ }
660
+ return match._id;
661
+ }
662
+
663
+ /** Extract { id, name } from a create/edit response (shape-tolerant). */
664
+ function describeTask(res) {
665
+ const t = (res && (res.task || res.data)) || res || {};
666
+ return {
667
+ id: t._id || t.id || 'unknown',
668
+ name: t.name != null ? t.name : 'unknown',
669
+ };
670
+ }
671
+
672
+ /**
673
+ * Shared create handler for tasks (type=1) and notes (type=2).
674
+ */
675
+ async function handleCreate(type, params, kindLabel) {
676
+ const { name, projectName, ...rest } = params;
677
+ const body = buildTaskBody(rest);
678
+ body.name = name;
679
+ body.type = type;
680
+
681
+ // Resolve projectName -> projectId via the API. When omitted, OMIT the key
682
+ // entirely so the backend applies the user's default project.
683
+ if (projectName) {
684
+ body.projectId = await resolveProjectId(projectName);
685
+ }
686
+
687
+ const res = await api.createTask(body);
688
+ const { id, name: savedName } = describeTask(res);
689
+ const where = body.projectId
690
+ ? ` in project ${body.projectId}`
691
+ : ' in your default project';
692
+ return toolOk(`Created ${kindLabel} "${savedName}" (id: ${id})${where}.`);
693
+ }
694
+
695
+ /**
696
+ * Shared edit handler for tasks and notes (same endpoint).
697
+ */
698
+ async function handleEdit(params, kindLabel) {
699
+ const { taskId, ...rest } = params;
700
+ const body = buildTaskBody(rest);
701
+ const changed = Object.keys(body);
702
+ if (changed.length === 0) {
703
+ return toolError('Nothing to update: provide at least one field to change.');
704
+ }
705
+ const res = await api.updateTask(taskId, body);
706
+ const { id, name: savedName } = describeTask(res);
707
+ return toolOk(
708
+ `Updated ${kindLabel} "${savedName}" (id: ${id}). Changed: ${changed.join(', ')}.`
709
+ );
710
+ }
711
+
712
+ // ---- planko_create_task ----
713
+ server.tool(
714
+ 'planko_create_task',
715
+ 'Create a Planko task (type=1) directly via your API key. Works without any folder setup. Provide a name (required); all other properties are optional. Optionally target a project by name (otherwise your default project is used).',
716
+ {
717
+ name: z.string().describe('Task name (required)'),
718
+ projectName: z
719
+ .string()
720
+ .optional()
721
+ .describe('Project name to create the task in (optional — omit for your default project)'),
722
+ ...taskProps,
723
+ },
724
+ async (params) => {
725
+ try {
726
+ return await handleCreate(1, params, 'task');
727
+ } catch (err) {
728
+ return toolError(`Create task failed: ${err.message}`);
729
+ }
730
+ }
731
+ );
732
+
733
+ // ---- planko_create_note ----
734
+ server.tool(
735
+ 'planko_create_note',
736
+ 'Create a Planko note (type=2) directly via your API key. Works without any folder setup. Provide a name (required); all other properties are optional. Optionally target a project by name (otherwise your default project is used).',
737
+ {
738
+ name: z.string().describe('Note name (required)'),
739
+ projectName: z
740
+ .string()
741
+ .optional()
742
+ .describe('Project name to create the note in (optional — omit for your default project)'),
743
+ ...taskProps,
744
+ },
745
+ async (params) => {
746
+ try {
747
+ return await handleCreate(2, params, 'note');
748
+ } catch (err) {
749
+ return toolError(`Create note failed: ${err.message}`);
750
+ }
751
+ }
752
+ );
753
+
754
+ // ---- planko_edit_task ----
755
+ server.tool(
756
+ 'planko_edit_task',
757
+ 'Edit an existing Planko task by id. Provide the taskId and any properties to change. The Markdown description is converted to BlockNote JSON.',
758
+ {
759
+ taskId: objectId.describe('Id of the task to edit (required)'),
760
+ name: z.string().optional().describe('New task name'),
761
+ type: z
762
+ .union([z.literal(1), z.literal(2)])
763
+ .optional()
764
+ .describe('Change type: 1=task, 2=note'),
765
+ projectId: objectId.optional().describe('Move to a different project by id'),
766
+ ...taskProps,
767
+ },
768
+ async (params) => {
769
+ try {
770
+ return await handleEdit(params, 'task');
771
+ } catch (err) {
772
+ return toolError(`Edit task failed: ${err.message}`);
773
+ }
774
+ }
775
+ );
776
+
777
+ // ---- planko_edit_note ----
778
+ server.tool(
779
+ 'planko_edit_note',
780
+ 'Edit an existing Planko note by id (shares the task edit endpoint). Provide the taskId and any properties to change. The Markdown description is converted to BlockNote JSON.',
781
+ {
782
+ taskId: objectId.describe('Id of the note to edit (required)'),
783
+ name: z.string().optional().describe('New note name'),
784
+ type: z
785
+ .union([z.literal(1), z.literal(2)])
786
+ .optional()
787
+ .describe('Change type: 1=task, 2=note'),
788
+ projectId: objectId.optional().describe('Move to a different project by id'),
789
+ ...taskProps,
790
+ },
791
+ async (params) => {
792
+ try {
793
+ return await handleEdit(params, 'note');
794
+ } catch (err) {
795
+ return toolError(`Edit note failed: ${err.message}`);
796
+ }
797
+ }
798
+ );
799
+
800
+ // ---- planko_complete_task ----
801
+ server.tool(
802
+ 'planko_complete_task',
803
+ 'Mark a Planko task complete (status=2) by id.',
804
+ {
805
+ taskId: objectId.describe('Id of the task to complete (required)'),
806
+ },
807
+ async ({ taskId }) => {
808
+ try {
809
+ const res = await api.completeTask(taskId);
810
+ const { id, name } = describeTask(res);
811
+ return toolOk(`Completed task "${name}" (id: ${id}).`);
812
+ } catch (err) {
813
+ return toolError(`Complete task failed: ${err.message}`);
814
+ }
815
+ }
816
+ );
817
+
818
+ // ---- planko_delete_task ----
819
+ server.tool(
820
+ 'planko_delete_task',
821
+ 'Delete a Planko task by id.',
822
+ {
823
+ taskId: objectId.describe('Id of the task to delete (required)'),
824
+ },
825
+ async ({ taskId }) => {
826
+ try {
827
+ await api.deleteTask(taskId);
828
+ return toolOk(`Deleted task (id: ${taskId}).`);
829
+ } catch (err) {
830
+ return toolError(`Delete task failed: ${err.message}`);
831
+ }
832
+ }
833
+ );
834
+
835
+ // ---- planko_delete_note ----
836
+ server.tool(
837
+ 'planko_delete_note',
838
+ 'Delete a Planko note by id (shares the task delete endpoint).',
839
+ {
840
+ taskId: objectId.describe('Id of the note to delete (required)'),
841
+ },
842
+ async ({ taskId }) => {
843
+ try {
844
+ await api.deleteTask(taskId);
845
+ return toolOk(`Deleted note (id: ${taskId}).`);
846
+ } catch (err) {
847
+ return toolError(`Delete note failed: ${err.message}`);
848
+ }
849
+ }
850
+ );
851
+
852
+ // --- Standalone READ tools (Part C) ---
853
+ // Read-only, user-scoped via API key; no folder setup required. List tools send
854
+ // `type` (1=tasks, 2=notes) and parametrized filters; view tools fetch one item
855
+ // by id and render its BlockNote description as Markdown. No mutation.
856
+
857
+ const datePlain = z
858
+ .string()
859
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD');
860
+
861
+ const sortBySchema = z
862
+ .string()
863
+ .describe(
864
+ "Sort as 'field:asc' or 'field:desc'. Allowed fields: dueDate, createdAt, updatedAt, priority, position, name. Default updatedAt:desc."
865
+ );
866
+
867
+ const STATUS_LABEL = { 1: 'open', 2: 'complete' };
868
+
869
+ /** Human label for a status code. */
870
+ function statusLabel(status) {
871
+ return STATUS_LABEL[status] || (status == null ? 'unknown' : String(status));
872
+ }
873
+
874
+ /** Render a task/note's tags as a comma list of names (fallback to ids). */
875
+ function formatTags(tags) {
876
+ if (!Array.isArray(tags) || tags.length === 0) return null;
877
+ return tags
878
+ .map((t) => (t && typeof t === 'object' ? t.name || t._id : t))
879
+ .filter(Boolean)
880
+ .join(', ');
881
+ }
882
+
883
+ /** Build the API filter params from tool params, resolving projectName. */
884
+ async function buildListParams(type, params) {
885
+ const { projectName, ...rest } = params;
886
+ const out = { type };
887
+ for (const [key, value] of Object.entries(rest)) {
888
+ if (value === undefined) continue;
889
+ out[key] = value;
890
+ }
891
+ if (projectName) {
892
+ out.projectId = await resolveProjectId(projectName);
893
+ }
894
+ return out;
895
+ }
896
+
897
+ /** One concise summary line per item for list output. */
898
+ function summarizeItem(item, index) {
899
+ const parts = [];
900
+ parts.push(`status: ${statusLabel(item.status)}`);
901
+ if (item.priority != null) parts.push(`priority: ${item.priority}`);
902
+ if (item.dueDate) parts.push(`due: ${item.dueDate}${item.time ? ` ${item.time}` : ''}`);
903
+ else if (item.datePlain) parts.push(`due: ${item.datePlain}${item.time ? ` ${item.time}` : ''}`);
904
+ const tagStr = formatTags(item.tags);
905
+ if (tagStr) parts.push(`tags: ${tagStr}`);
906
+ if (item.projectId) parts.push(`project: ${item.projectId}`);
907
+ return (
908
+ `${index}. ${item.name || '(untitled)'} [id: ${item._id}]\n` +
909
+ ` ${parts.join(' | ')}`
910
+ );
911
+ }
912
+
913
+ /** Render a list response as a concise, readable summary (not raw JSON). */
914
+ function renderList(result, kindLabelPlural) {
915
+ const tasks = Array.isArray(result?.tasks) ? result.tasks : [];
916
+ const total = result?.total ?? tasks.length;
917
+ const page = result?.page ?? 1;
918
+ const limit = result?.limit ?? tasks.length;
919
+
920
+ if (tasks.length === 0) {
921
+ return `No ${kindLabelPlural} found (total: ${total}, page ${page}).`;
922
+ }
923
+
924
+ const lines = tasks.map((t, i) => summarizeItem(t, i + 1));
925
+ const footer = `\nShowing ${tasks.length} of ${total} ${kindLabelPlural} (page ${page}, limit ${limit}).`;
926
+ return `${lines.join('\n')}\n${footer}`;
927
+ }
928
+
929
+ /** Render one item's full detail with the description converted to Markdown. */
930
+ function renderItem(item, kindLabel) {
931
+ const lines = [];
932
+ lines.push(`${item.name || '(untitled)'}`);
933
+ lines.push(`id: ${item._id}`);
934
+ lines.push(`type: ${item.type === 2 ? 'note' : 'task'} (${kindLabel})`);
935
+ lines.push(`status: ${statusLabel(item.status)}`);
936
+ if (item.priority != null) lines.push(`priority: ${item.priority}`);
937
+ if (item.dueDate) lines.push(`due: ${item.dueDate}${item.time ? ` ${item.time}` : ''}`);
938
+ else if (item.datePlain) lines.push(`due: ${item.datePlain}${item.time ? ` ${item.time}` : ''}`);
939
+ const tagStr = formatTags(item.tags);
940
+ if (tagStr) lines.push(`tags: ${tagStr}`);
941
+ if (item.projectId) lines.push(`project: ${item.projectId}`);
942
+ if (item.parentId) lines.push(`parent: ${item.parentId}`);
943
+ if (item.createdAt) lines.push(`created: ${item.createdAt}`);
944
+ if (item.updatedAt) lines.push(`updated: ${item.updatedAt}`);
945
+
946
+ const md = descriptionToMarkdown(item.description);
947
+ lines.push('');
948
+ lines.push('--- description ---');
949
+ lines.push(md && md.trim() ? md : '(empty)');
950
+ return lines.join('\n');
951
+ }
952
+
953
+ /** Extract a single item from a view response (shape-tolerant). */
954
+ function unwrapItem(res) {
955
+ return (res && (res.task || res.data)) || res || {};
956
+ }
957
+
958
+ // Shared list filter surface (task tools use all; note tools use the applicable subset).
959
+ const listStatus = z
960
+ .union([z.literal(1), z.literal(2)])
961
+ .optional()
962
+ .describe('Filter by status: 1=open, 2=complete');
963
+ const listShowCompleted = z
964
+ .boolean()
965
+ .optional()
966
+ .describe('When false/omitted (and no explicit status), completed items are excluded');
967
+ const listProjectName = z
968
+ .string()
969
+ .optional()
970
+ .describe('Filter to a project by name (resolved to its id)');
971
+ const listProjectId = objectId.optional().describe('Filter to a project by id');
972
+ const listTags = z.array(objectId).optional().describe('Filter by tag ids (AND — item must have all)');
973
+ const listSearch = z.string().optional().describe('Case-insensitive search on the name');
974
+ const listSortBy = sortBySchema.optional();
975
+ const listLimit = z
976
+ .number()
977
+ .int()
978
+ .min(1)
979
+ .max(200)
980
+ .optional()
981
+ .describe('Max items to return (default 50, max 200)');
982
+ const listPage = z.number().int().min(1).optional().describe('Page number (default 1)');
983
+
984
+ // ---- planko_list_tasks ----
985
+ server.tool(
986
+ 'planko_list_tasks',
987
+ 'List your Planko tasks (type=1) via your API key, no folder setup required. Filters are optional. Without projectId/projectName the list is scoped to your own tasks; with a project it includes that project\'s (incl. workspace-shared) tasks. Note: recurring tasks appear as separate dated occurrences. Returns a concise summary, not raw JSON.',
988
+ {
989
+ status: listStatus,
990
+ showCompleted: listShowCompleted,
991
+ priority: z
992
+ .union([z.literal(1), z.literal(2), z.literal(3)])
993
+ .optional()
994
+ .describe('Filter by priority: 1, 2, or 3'),
995
+ projectName: listProjectName,
996
+ projectId: listProjectId,
997
+ parentId: objectId.optional().describe('List subtasks of this parent task id'),
998
+ tags: listTags,
999
+ search: listSearch,
1000
+ dueDateFrom: datePlain.optional().describe('Inclusive lower bound on due date (YYYY-MM-DD)'),
1001
+ dueDateTo: datePlain.optional().describe('Inclusive upper bound on due date (YYYY-MM-DD)'),
1002
+ sortBy: listSortBy,
1003
+ limit: listLimit,
1004
+ page: listPage,
1005
+ },
1006
+ async (params) => {
1007
+ try {
1008
+ const apiParams = await buildListParams(1, params);
1009
+ const result = await api.listTasks(apiParams);
1010
+ return toolOk(renderList(result, 'tasks'));
1011
+ } catch (err) {
1012
+ return toolError(`List tasks failed: ${err.message}`);
1013
+ }
1014
+ }
1015
+ );
1016
+
1017
+ // ---- planko_list_notes ----
1018
+ server.tool(
1019
+ 'planko_list_notes',
1020
+ 'List your Planko notes (type=2) via your API key, no folder setup required. Filters are optional. Without projectId/projectName the list is scoped to your own notes; with a project it includes that project\'s (incl. workspace-shared) notes. Deleted and recurring-copy notes are excluded. Returns a concise summary, not raw JSON.',
1021
+ {
1022
+ showCompleted: listShowCompleted,
1023
+ projectName: listProjectName,
1024
+ projectId: listProjectId,
1025
+ tags: listTags,
1026
+ search: listSearch,
1027
+ sortBy: listSortBy,
1028
+ limit: listLimit,
1029
+ page: listPage,
1030
+ },
1031
+ async (params) => {
1032
+ try {
1033
+ const apiParams = await buildListParams(2, params);
1034
+ const result = await api.listTasks(apiParams);
1035
+ return toolOk(renderList(result, 'notes'));
1036
+ } catch (err) {
1037
+ return toolError(`List notes failed: ${err.message}`);
1038
+ }
1039
+ }
1040
+ );
1041
+
1042
+ // ---- planko_view_task ----
1043
+ server.tool(
1044
+ 'planko_view_task',
1045
+ 'View one Planko task by id, with its description rendered as Markdown. The type is informational — this endpoint returns the item regardless of task/note type.',
1046
+ {
1047
+ taskId: objectId.describe('Id of the task to view (required)'),
1048
+ },
1049
+ async ({ taskId }) => {
1050
+ try {
1051
+ const res = await api.getTask(taskId);
1052
+ return toolOk(renderItem(unwrapItem(res), 'task'));
1053
+ } catch (err) {
1054
+ return toolError(`View task failed: ${err.message}`);
1055
+ }
1056
+ }
1057
+ );
1058
+
1059
+ // ---- planko_view_note ----
1060
+ server.tool(
1061
+ 'planko_view_note',
1062
+ 'View one Planko note by id, with its description rendered as Markdown. The type is informational — this endpoint returns the item regardless of task/note type.',
1063
+ {
1064
+ taskId: objectId.describe('Id of the note to view (required)'),
1065
+ },
1066
+ async ({ taskId }) => {
1067
+ try {
1068
+ const res = await api.getTask(taskId);
1069
+ return toolOk(renderItem(unwrapItem(res), 'note'));
1070
+ } catch (err) {
1071
+ return toolError(`View note failed: ${err.message}`);
1072
+ }
1073
+ }
1074
+ );
1075
+
564
1076
  // --- Start server ---
565
1077
 
566
1078
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "planko-mcp-server",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for syncing Planko tasks with local Markdown files",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -51,35 +51,107 @@ export function createApiClient({ apiKey, apiBase }) {
51
51
  },
52
52
 
53
53
  /**
54
- * GET /mcp-project-sync/status?projectId=...&mcpLastSyncDate=...
54
+ * GET /mcp-project-sync/status?projectId=...&mcpLastSyncDate=...&type=...
55
+ * `type` (1=tasks, 2=notes) is appended whenever set, independent of mcpLastSyncDate.
55
56
  */
56
- async status(projectId, mcpLastSyncDate) {
57
+ async status(projectId, mcpLastSyncDate, type) {
57
58
  let qs = `projectId=${projectId}`;
58
59
  if (mcpLastSyncDate != null) {
59
60
  qs += `&mcpLastSyncDate=${mcpLastSyncDate}`;
60
61
  }
62
+ if (type === 1 || type === 2) {
63
+ qs += `&type=${type}`;
64
+ }
61
65
  return request('GET', `/mcp-project-sync/status?${qs}`);
62
66
  },
63
67
 
64
68
  /**
65
- * GET /mcp-project-sync/pull?projectId=...&mcpLastSyncDate=...
69
+ * GET /mcp-project-sync/pull?projectId=...&mcpLastSyncDate=...&type=...
66
70
  */
67
- async pull(projectId, mcpLastSyncDate) {
71
+ async pull(projectId, mcpLastSyncDate, type) {
68
72
  let qs = `projectId=${projectId}`;
69
73
  if (mcpLastSyncDate != null) {
70
74
  qs += `&mcpLastSyncDate=${mcpLastSyncDate}`;
71
75
  }
76
+ if (type === 1 || type === 2) {
77
+ qs += `&type=${type}`;
78
+ }
72
79
  return request('GET', `/mcp-project-sync/pull?${qs}`);
73
80
  },
74
81
 
75
82
  /**
76
83
  * POST /mcp-project-sync/push
84
+ * `type` (1=tasks, 2=notes) is included in the body whenever set.
85
+ */
86
+ async push(projectId, tasks, type) {
87
+ const body = { projectId, tasks };
88
+ if (type === 1 || type === 2) {
89
+ body.type = type;
90
+ }
91
+ return request('POST', '/mcp-project-sync/push', body);
92
+ },
93
+
94
+ // --- Standalone CRUD (user-scoped, no folder sync required) ---
95
+
96
+ /**
97
+ * POST /mcp-project-sync/tasks — create a task (type=1) or note (type=2).
98
+ */
99
+ async createTask(body) {
100
+ return request('POST', '/mcp-project-sync/tasks', body);
101
+ },
102
+
103
+ /**
104
+ * PATCH /mcp-project-sync/tasks/:taskId — edit a task or note.
105
+ */
106
+ async updateTask(taskId, body) {
107
+ return request('PATCH', `/mcp-project-sync/tasks/${taskId}`, body);
108
+ },
109
+
110
+ /**
111
+ * POST /mcp-project-sync/tasks/:taskId/complete — mark complete (status=2).
112
+ */
113
+ async completeTask(taskId) {
114
+ return request('POST', `/mcp-project-sync/tasks/${taskId}/complete`);
115
+ },
116
+
117
+ /**
118
+ * DELETE /mcp-project-sync/tasks/:taskId — delete a task or note.
119
+ */
120
+ async deleteTask(taskId) {
121
+ return request('DELETE', `/mcp-project-sync/tasks/${taskId}`);
122
+ },
123
+
124
+ // --- Read (user-scoped, no folder sync required) ---
125
+
126
+ /**
127
+ * GET /mcp-project-sync/tasks — list the user's tasks (type=1) or notes
128
+ * (type=2) with parametrized filters. Builds the query string from `params`,
129
+ * omitting undefined/null values. A `tags` array is serialized to a CSV
130
+ * string (the backend splits on comma).
131
+ */
132
+ async listTasks(params = {}) {
133
+ const qs = new URLSearchParams();
134
+ for (const [key, value] of Object.entries(params)) {
135
+ if (value === undefined || value === null) continue;
136
+ if (key === 'tags' && Array.isArray(value)) {
137
+ if (value.length === 0) continue;
138
+ qs.set(key, value.join(','));
139
+ } else {
140
+ qs.set(key, String(value));
141
+ }
142
+ }
143
+ const query = qs.toString();
144
+ return request(
145
+ 'GET',
146
+ `/mcp-project-sync/tasks${query ? `?${query}` : ''}`
147
+ );
148
+ },
149
+
150
+ /**
151
+ * GET /mcp-project-sync/tasks/:taskId — fetch one task or note by id.
77
152
  */
78
- async push(projectId, tasks) {
79
- return request('POST', '/mcp-project-sync/push', {
80
- projectId,
81
- tasks,
82
- });
153
+ async getTask(taskId) {
154
+ return request('GET', `/mcp-project-sync/tasks/${taskId}`);
83
155
  },
84
156
  };
85
157
  }
package/src/sync-state.js CHANGED
@@ -77,8 +77,8 @@ export function writeSyncState(folder, data) {
77
77
  writeFileSync(join(folder, SYNC_FILE), JSON.stringify(safe, null, 2));
78
78
  }
79
79
 
80
- export function createSyncState(projectId, projectName) {
81
- return {
80
+ export function createSyncState(projectId, projectName, syncType) {
81
+ const state = {
82
82
  project: {
83
83
  _id: projectId,
84
84
  name: projectName,
@@ -88,6 +88,11 @@ export function createSyncState(projectId, projectName) {
88
88
  mcpLastSyncPushChanges: [],
89
89
  tasks: [],
90
90
  };
91
+ // syncType: 1 = tasks, 2 = notes. Omitted (undefined) => legacy type-agnostic sync.
92
+ if (syncType === 1 || syncType === 2) {
93
+ state.syncType = syncType;
94
+ }
95
+ return state;
91
96
  }
92
97
 
93
98
  // --- Local file operations ---