planko-mcp-server 0.4.2 → 0.5.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 +43 -1
- package/index.js +68 -12
- package/package.json +2 -2
- package/src/projectLock.js +81 -0
package/README.md
CHANGED
|
@@ -158,7 +158,9 @@ These also work with the **same API key** and require **no folder setup**. `list
|
|
|
158
158
|
| `planko_view_task` | View one task by id | `taskId` (required) |
|
|
159
159
|
| `planko_view_note` | View one note by id (shares the view endpoint) | `taskId` (required) |
|
|
160
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.
|
|
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. Use `assigneeId` to narrow a project listing to one member; omit it for all 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
|
+
**Owner in output.** When the backend populates an item's `userId` as an object `{ _id, name, email }`, list lines and `view_*` detail append `owner: <name>` so you can tell whose task it is in a multi-member project listing. If `userId` is a bare id/string, nothing extra is shown.
|
|
162
164
|
|
|
163
165
|
**List filters** (`planko_list_tasks` accepts all; `planko_list_notes` accepts the applicable subset):
|
|
164
166
|
|
|
@@ -169,6 +171,7 @@ These also work with the **same API key** and require **no folder setup**. `list
|
|
|
169
171
|
| `priority` | `1` \| `2` \| `3` | (tasks only) |
|
|
170
172
|
| `projectName` | string | Resolved to a project id via your accessible projects |
|
|
171
173
|
| `projectId` | ObjectId | Alternative to `projectName` |
|
|
174
|
+
| `assigneeId` | ObjectId | Filter to a single project member by their user id. Omit for **all members**. Applies within a project listing (needs a project scope to be meaningful) |
|
|
172
175
|
| `parentId` | ObjectId | (tasks only) list subtasks of this parent |
|
|
173
176
|
| `tags` | array of ObjectId | AND semantics — item must have all tags |
|
|
174
177
|
| `search` | string | Case-insensitive match on the name |
|
|
@@ -213,6 +216,45 @@ Sync order: delete, pull, push. Local changes overwrite remote on conflict.
|
|
|
213
216
|
|---|---|---|
|
|
214
217
|
| `PLANKO_API_KEY` | Yes | Your MCP API key from app.planko.io/integrations |
|
|
215
218
|
| `PLANKO_API_BASE` | No | API base URL override (defaults to production) |
|
|
219
|
+
| `PLANKO_PROJECT_LOCK` | No | Confine every operation to a single project id (see below). **Off by default** — when unset the server behaves exactly as before. |
|
|
220
|
+
|
|
221
|
+
### Project lock (`PLANKO_PROJECT_LOCK`)
|
|
222
|
+
|
|
223
|
+
Set `PLANKO_PROJECT_LOCK` to a project id to restrict this server instance to that
|
|
224
|
+
one project. This is a **hard override the agent cannot bypass** — useful when a
|
|
225
|
+
shared automation should only ever touch a single project.
|
|
226
|
+
|
|
227
|
+
When set, it changes behavior as follows:
|
|
228
|
+
|
|
229
|
+
- **Create** (`create_task` / `create_note`): the new item is always created in the
|
|
230
|
+
locked project; any `projectName` the caller passes is ignored (and not resolved).
|
|
231
|
+
- **List** (`list_tasks` / `list_notes`): always scoped to the locked project
|
|
232
|
+
(ignoring `projectName`/`projectId`), which yields the all-members listing for
|
|
233
|
+
that project. Combine with `assigneeId` to narrow to one member.
|
|
234
|
+
- **Edit** (`edit_task` / `edit_note`): if the caller supplies a `projectId`, it is
|
|
235
|
+
forced back to the locked project (an edit can never move a task out of it). If no
|
|
236
|
+
project field is supplied, the project is left unchanged.
|
|
237
|
+
- **By-id operations** (`edit_*`, `complete_task`, `delete_*`, `view_*`): the target
|
|
238
|
+
task is fetched first, and the operation is refused with an error if the task does
|
|
239
|
+
not belong to the locked project.
|
|
240
|
+
|
|
241
|
+
**Off by default:** when `PLANKO_PROJECT_LOCK` is unset (or blank), none of the above
|
|
242
|
+
applies and the server is fully backward-compatible.
|
|
243
|
+
|
|
244
|
+
```json
|
|
245
|
+
{
|
|
246
|
+
"mcpServers": {
|
|
247
|
+
"planko": {
|
|
248
|
+
"command": "npx",
|
|
249
|
+
"args": ["-y", "planko-mcp-server"],
|
|
250
|
+
"env": {
|
|
251
|
+
"PLANKO_API_KEY": "your-api-key",
|
|
252
|
+
"PLANKO_PROJECT_LOCK": "6742635e764bda007ab987ed"
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
```
|
|
216
258
|
|
|
217
259
|
## How it works
|
|
218
260
|
|
package/index.js
CHANGED
|
@@ -21,6 +21,12 @@ import { join } from 'node:path';
|
|
|
21
21
|
|
|
22
22
|
import { createApiClient } from './src/api.js';
|
|
23
23
|
import { isBlankValue, applyDueDateFallback } from './src/sanitize.js';
|
|
24
|
+
import {
|
|
25
|
+
lockProjectId,
|
|
26
|
+
sanitizeFilters,
|
|
27
|
+
ownerName,
|
|
28
|
+
assertProjectAllowed,
|
|
29
|
+
} from './src/projectLock.js';
|
|
24
30
|
import {
|
|
25
31
|
blockNoteToMarkdown,
|
|
26
32
|
markdownToBlockNote,
|
|
@@ -54,6 +60,11 @@ if (!API_KEY) {
|
|
|
54
60
|
|
|
55
61
|
const api = createApiClient({ apiKey: API_KEY });
|
|
56
62
|
|
|
63
|
+
// Optional project lock. When set, EVERY operation is confined to this single
|
|
64
|
+
// project id (a hard override the agent cannot bypass). Read once at startup.
|
|
65
|
+
// When null (the default) behavior is identical to before this feature.
|
|
66
|
+
const PROJECT_LOCK = (process.env.PLANKO_PROJECT_LOCK || '').trim() || null;
|
|
67
|
+
|
|
57
68
|
// --- Tool helpers ---
|
|
58
69
|
|
|
59
70
|
function toolOk(text) {
|
|
@@ -361,7 +372,7 @@ async function executeSync(projectId, folder, sync) {
|
|
|
361
372
|
|
|
362
373
|
const server = new McpServer({
|
|
363
374
|
name: 'planko-mcp-server',
|
|
364
|
-
version: '0.
|
|
375
|
+
version: '0.5.0',
|
|
365
376
|
});
|
|
366
377
|
|
|
367
378
|
// ---- planko_setup ----
|
|
@@ -671,6 +682,16 @@ function describeTask(res) {
|
|
|
671
682
|
};
|
|
672
683
|
}
|
|
673
684
|
|
|
685
|
+
/**
|
|
686
|
+
* When PROJECT_LOCK is set, fetch a by-id task and refuse the operation unless
|
|
687
|
+
* it belongs to the locked project. No-op when the lock is null.
|
|
688
|
+
*/
|
|
689
|
+
async function assertTaskInLock(taskId) {
|
|
690
|
+
if (!PROJECT_LOCK) return;
|
|
691
|
+
const res = await api.getTask(taskId);
|
|
692
|
+
assertProjectAllowed(PROJECT_LOCK, unwrapItem(res));
|
|
693
|
+
}
|
|
694
|
+
|
|
674
695
|
/**
|
|
675
696
|
* Shared create handler for tasks (type=1) and notes (type=2).
|
|
676
697
|
*/
|
|
@@ -680,9 +701,13 @@ async function handleCreate(type, params, kindLabel) {
|
|
|
680
701
|
body.name = name;
|
|
681
702
|
body.type = type;
|
|
682
703
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
704
|
+
if (PROJECT_LOCK) {
|
|
705
|
+
// Hard override: always the locked project; ignore any caller project field
|
|
706
|
+
// and do NOT resolve projectName.
|
|
707
|
+
body.projectId = lockProjectId(PROJECT_LOCK, body.projectId);
|
|
708
|
+
} else if (!isBlankValue(projectName)) {
|
|
709
|
+
// Resolve projectName -> projectId via the API. When omitted, OMIT the key
|
|
710
|
+
// entirely so the backend applies the user's default project.
|
|
686
711
|
body.projectId = await resolveProjectId(projectName);
|
|
687
712
|
}
|
|
688
713
|
|
|
@@ -704,6 +729,16 @@ async function handleEdit(params, kindLabel) {
|
|
|
704
729
|
if (changed.length === 0) {
|
|
705
730
|
return toolError('Nothing to update: provide at least one field to change.');
|
|
706
731
|
}
|
|
732
|
+
if (PROJECT_LOCK) {
|
|
733
|
+
// Refuse to touch a task outside the locked project.
|
|
734
|
+
await assertTaskInLock(taskId);
|
|
735
|
+
// If the caller tried to move the task to another project, force it back to
|
|
736
|
+
// the lock (never let an edit move a task OUT of the locked project). If no
|
|
737
|
+
// project field was passed, leave the project unchanged.
|
|
738
|
+
if ('projectId' in body) {
|
|
739
|
+
body.projectId = lockProjectId(PROJECT_LOCK, body.projectId);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
707
742
|
const res = await api.updateTask(taskId, body);
|
|
708
743
|
const { id, name: savedName } = describeTask(res);
|
|
709
744
|
return toolOk(
|
|
@@ -808,6 +843,7 @@ server.tool(
|
|
|
808
843
|
},
|
|
809
844
|
async ({ taskId }) => {
|
|
810
845
|
try {
|
|
846
|
+
await assertTaskInLock(taskId);
|
|
811
847
|
const res = await api.completeTask(taskId);
|
|
812
848
|
const { id, name } = describeTask(res);
|
|
813
849
|
return toolOk(`Completed task "${name}" (id: ${id}).`);
|
|
@@ -826,6 +862,7 @@ server.tool(
|
|
|
826
862
|
},
|
|
827
863
|
async ({ taskId }) => {
|
|
828
864
|
try {
|
|
865
|
+
await assertTaskInLock(taskId);
|
|
829
866
|
await api.deleteTask(taskId);
|
|
830
867
|
return toolOk(`Deleted task (id: ${taskId}).`);
|
|
831
868
|
} catch (err) {
|
|
@@ -843,6 +880,7 @@ server.tool(
|
|
|
843
880
|
},
|
|
844
881
|
async ({ taskId }) => {
|
|
845
882
|
try {
|
|
883
|
+
await assertTaskInLock(taskId);
|
|
846
884
|
await api.deleteTask(taskId);
|
|
847
885
|
return toolOk(`Deleted note (id: ${taskId}).`);
|
|
848
886
|
} catch (err) {
|
|
@@ -885,12 +923,17 @@ function formatTags(tags) {
|
|
|
885
923
|
/** Build the API filter params from tool params, resolving projectName. */
|
|
886
924
|
async function buildListParams(type, params) {
|
|
887
925
|
const { projectName, ...rest } = params;
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
926
|
+
// Sanitize scalar filters (drops blank/placeholder values, incl. a blank
|
|
927
|
+
// assigneeId => "all members"). projectId/assigneeId pass through here.
|
|
928
|
+
const out = { type, ...sanitizeFilters(rest) };
|
|
929
|
+
if (PROJECT_LOCK) {
|
|
930
|
+
// Hard override: force the project regardless of caller input. Do NOT
|
|
931
|
+
// resolve projectName here — the override wins unconditionally, so an
|
|
932
|
+
// invalid projectName must not be able to fail an otherwise-locked list
|
|
933
|
+
// (keeps list consistent with create, which also ignores projectName).
|
|
934
|
+
out.projectId = lockProjectId(PROJECT_LOCK, out.projectId);
|
|
935
|
+
} else if (!isBlankValue(projectName)) {
|
|
936
|
+
// Unlocked (default) path: unchanged behavior.
|
|
894
937
|
out.projectId = await resolveProjectId(projectName);
|
|
895
938
|
}
|
|
896
939
|
return out;
|
|
@@ -906,6 +949,8 @@ function summarizeItem(item, index) {
|
|
|
906
949
|
const tagStr = formatTags(item.tags);
|
|
907
950
|
if (tagStr) parts.push(`tags: ${tagStr}`);
|
|
908
951
|
if (item.projectId) parts.push(`project: ${item.projectId}`);
|
|
952
|
+
const owner = ownerName(item.userId);
|
|
953
|
+
if (owner) parts.push(`owner: ${owner}`);
|
|
909
954
|
return (
|
|
910
955
|
`${index}. ${item.name || '(untitled)'} [id: ${item._id}]\n` +
|
|
911
956
|
` ${parts.join(' | ')}`
|
|
@@ -941,6 +986,8 @@ function renderItem(item, kindLabel) {
|
|
|
941
986
|
const tagStr = formatTags(item.tags);
|
|
942
987
|
if (tagStr) lines.push(`tags: ${tagStr}`);
|
|
943
988
|
if (item.projectId) lines.push(`project: ${item.projectId}`);
|
|
989
|
+
const owner = ownerName(item.userId);
|
|
990
|
+
if (owner) lines.push(`owner: ${owner}`);
|
|
944
991
|
if (item.parentId) lines.push(`parent: ${item.parentId}`);
|
|
945
992
|
if (item.createdAt) lines.push(`created: ${item.createdAt}`);
|
|
946
993
|
if (item.updatedAt) lines.push(`updated: ${item.updatedAt}`);
|
|
@@ -971,6 +1018,9 @@ const listProjectName = z
|
|
|
971
1018
|
.optional()
|
|
972
1019
|
.describe('Filter to a project by name (resolved to its id)');
|
|
973
1020
|
const listProjectId = objectId.optional().describe('Filter to a project by id');
|
|
1021
|
+
const listAssigneeId = objectId
|
|
1022
|
+
.optional()
|
|
1023
|
+
.describe('Filter to a single project member by their user id (omit for all members)');
|
|
974
1024
|
const listTags = z.array(objectId).optional().describe('Filter by tag ids (AND — item must have all)');
|
|
975
1025
|
const listSearch = z.string().optional().describe('Case-insensitive search on the name');
|
|
976
1026
|
const listSortBy = sortBySchema.optional();
|
|
@@ -996,6 +1046,7 @@ server.tool(
|
|
|
996
1046
|
.describe('Filter by priority: 1, 2, or 3'),
|
|
997
1047
|
projectName: listProjectName,
|
|
998
1048
|
projectId: listProjectId,
|
|
1049
|
+
assigneeId: listAssigneeId,
|
|
999
1050
|
parentId: objectId.optional().describe('List subtasks of this parent task id'),
|
|
1000
1051
|
tags: listTags,
|
|
1001
1052
|
search: listSearch,
|
|
@@ -1024,6 +1075,7 @@ server.tool(
|
|
|
1024
1075
|
showCompleted: listShowCompleted,
|
|
1025
1076
|
projectName: listProjectName,
|
|
1026
1077
|
projectId: listProjectId,
|
|
1078
|
+
assigneeId: listAssigneeId,
|
|
1027
1079
|
tags: listTags,
|
|
1028
1080
|
search: listSearch,
|
|
1029
1081
|
sortBy: listSortBy,
|
|
@@ -1051,7 +1103,9 @@ server.tool(
|
|
|
1051
1103
|
async ({ taskId }) => {
|
|
1052
1104
|
try {
|
|
1053
1105
|
const res = await api.getTask(taskId);
|
|
1054
|
-
|
|
1106
|
+
const item = unwrapItem(res);
|
|
1107
|
+
assertProjectAllowed(PROJECT_LOCK, item);
|
|
1108
|
+
return toolOk(renderItem(item, 'task'));
|
|
1055
1109
|
} catch (err) {
|
|
1056
1110
|
return toolError(`View task failed: ${err.message}`);
|
|
1057
1111
|
}
|
|
@@ -1068,7 +1122,9 @@ server.tool(
|
|
|
1068
1122
|
async ({ taskId }) => {
|
|
1069
1123
|
try {
|
|
1070
1124
|
const res = await api.getTask(taskId);
|
|
1071
|
-
|
|
1125
|
+
const item = unwrapItem(res);
|
|
1126
|
+
assertProjectAllowed(PROJECT_LOCK, item);
|
|
1127
|
+
return toolOk(renderItem(item, 'note'));
|
|
1072
1128
|
} catch (err) {
|
|
1073
1129
|
return toolError(`View note failed: ${err.message}`);
|
|
1074
1130
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "planko-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "MCP server for syncing Planko tasks with local Markdown files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
19
|
"test": "vitest run",
|
|
20
|
-
"prepublishOnly": "node --check index.js && node --check src/api.js && node --check src/converters.js && node --check src/sync-state.js"
|
|
20
|
+
"prepublishOnly": "node --check index.js && node --check src/api.js && node --check src/converters.js && node --check src/sync-state.js && node --check src/projectLock.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@modelcontextprotocol/sdk": "^1.12.1"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-lock decision logic (pure, testable).
|
|
3
|
+
*
|
|
4
|
+
* When the env var PLANKO_PROJECT_LOCK is set, the MCP server restricts EVERY
|
|
5
|
+
* operation to that single project id — a hard override the calling agent
|
|
6
|
+
* cannot bypass. When the lock is null (the default), all functions here behave
|
|
7
|
+
* as pass-throughs so existing callers (e.g. the "clawis" agent) are unaffected.
|
|
8
|
+
*
|
|
9
|
+
* These helpers are kept free of any I/O so they can be unit-tested directly;
|
|
10
|
+
* the server entrypoint (index.js) wires them into the tool handlers.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { isBlankValue } from './sanitize.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Decide the effective projectId.
|
|
17
|
+
* - lock set (truthy) → always returns `lock` (ignores `requested`).
|
|
18
|
+
* - lock null/blank → returns `requested` unchanged (pass-through).
|
|
19
|
+
*/
|
|
20
|
+
export function lockProjectId(lock, requested) {
|
|
21
|
+
if (lock) return lock;
|
|
22
|
+
return requested;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Sanitize a bag of list/query filter params, dropping blank/placeholder values
|
|
27
|
+
* (empty strings, all-zero ObjectId, empty arrays) via isBlankValue. Used for
|
|
28
|
+
* every list filter including the optional `assigneeId` — when it is blank the
|
|
29
|
+
* key is omitted, which the backend treats as "all members".
|
|
30
|
+
* Returns a new object; does not mutate the input.
|
|
31
|
+
*/
|
|
32
|
+
export function sanitizeFilters(params) {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [key, value] of Object.entries(params || {})) {
|
|
35
|
+
if (isBlankValue(value)) continue;
|
|
36
|
+
out[key] = value;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Format an owner label from an item's `userId` field.
|
|
43
|
+
* The backend may populate `userId` as an object { _id, name, email } on
|
|
44
|
+
* list/view responses. Returns the owner's name when present, else null.
|
|
45
|
+
* Tolerates a bare string/id (returns null) and never throws.
|
|
46
|
+
*/
|
|
47
|
+
export function ownerName(userId) {
|
|
48
|
+
if (
|
|
49
|
+
userId &&
|
|
50
|
+
typeof userId === 'object' &&
|
|
51
|
+
!Array.isArray(userId) &&
|
|
52
|
+
typeof userId.name === 'string' &&
|
|
53
|
+
userId.name.trim() !== ''
|
|
54
|
+
) {
|
|
55
|
+
return userId.name;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Extract a comparable project id string from a task's `projectId`, which may be
|
|
62
|
+
* a bare id string or a populated object { _id, ... }.
|
|
63
|
+
*/
|
|
64
|
+
function projectIdOf(task) {
|
|
65
|
+
const pid = task && task.projectId;
|
|
66
|
+
if (pid && typeof pid === 'object') return pid._id != null ? String(pid._id) : '';
|
|
67
|
+
return pid != null ? String(pid) : '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Guard a by-id operation against the lock. When `lock` is null this is a no-op.
|
|
72
|
+
* When `lock` is set, throws unless the task belongs to the locked project.
|
|
73
|
+
* A missing/unknown projectId also throws (cannot prove it is in-scope).
|
|
74
|
+
*/
|
|
75
|
+
export function assertProjectAllowed(lock, task) {
|
|
76
|
+
if (!lock) return;
|
|
77
|
+
const pid = projectIdOf(task);
|
|
78
|
+
if (!pid || pid !== String(lock)) {
|
|
79
|
+
throw new Error('This agent is restricted to the "planko" project.');
|
|
80
|
+
}
|
|
81
|
+
}
|