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 +115 -7
- package/index.js +522 -10
- package/package.json +1 -1
- package/src/api.js +81 -9
- package/src/sync-state.js +7 -2
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
|
-
- **
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
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
|
|
79
|
-
return request('
|
|
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
|
-
|
|
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 ---
|