planko-mcp-server 0.3.0 → 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 +45 -2
- package/index.js +224 -0
- package/package.json +1 -1
- package/src/api.js +33 -0
package/README.md
CHANGED
|
@@ -4,12 +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
8
|
- **Type-aware sync**: each folder syncs either **tasks** (type=1) or **notes** (type=2), chosen at setup
|
|
9
9
|
- **Multi-folder**: sync multiple projects to different local folders
|
|
10
10
|
- **Bidirectional sync**: pull remote changes, push local changes
|
|
11
11
|
- **Delete sync**: deleted local files remove tasks on server; deleted tasks remove local files
|
|
12
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
|
|
13
14
|
- **User-scoped API key**: one key works across all your projects
|
|
14
15
|
|
|
15
16
|
## Getting Started
|
|
@@ -86,7 +87,7 @@ Tasks are pulled as `.md` files into your folder. Local changes are pushed back
|
|
|
86
87
|
|
|
87
88
|
## Tools
|
|
88
89
|
|
|
89
|
-
The server exposes
|
|
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).
|
|
90
91
|
|
|
91
92
|
### Folder-sync tools
|
|
92
93
|
|
|
@@ -146,6 +147,48 @@ planko_complete_task(taskId: "665f...c0")
|
|
|
146
147
|
planko_delete_note(taskId: "665f...aa")
|
|
147
148
|
```
|
|
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
|
+
|
|
149
192
|
### Sync preview
|
|
150
193
|
|
|
151
194
|
```
|
package/index.js
CHANGED
|
@@ -849,6 +849,230 @@ server.tool(
|
|
|
849
849
|
}
|
|
850
850
|
);
|
|
851
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
|
+
|
|
852
1076
|
// --- Start server ---
|
|
853
1077
|
|
|
854
1078
|
async function main() {
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -120,5 +120,38 @@ export function createApiClient({ apiKey, apiBase }) {
|
|
|
120
120
|
async deleteTask(taskId) {
|
|
121
121
|
return request('DELETE', `/mcp-project-sync/tasks/${taskId}`);
|
|
122
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.
|
|
152
|
+
*/
|
|
153
|
+
async getTask(taskId) {
|
|
154
|
+
return request('GET', `/mcp-project-sync/tasks/${taskId}`);
|
|
155
|
+
},
|
|
123
156
|
};
|
|
124
157
|
}
|