planko-mcp-server 0.4.1 → 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 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
@@ -20,7 +20,13 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
 
22
22
  import { createApiClient } from './src/api.js';
23
- import { isBlankValue } from './src/sanitize.js';
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.2.0',
375
+ version: '0.5.0',
365
376
  });
366
377
 
367
378
  // ---- planko_setup ----
@@ -637,6 +648,7 @@ function buildTaskBody(params) {
637
648
  if (isBlankValue(value)) continue;
638
649
  body[key] = value;
639
650
  }
651
+ applyDueDateFallback(body);
640
652
  if ('description' in body) {
641
653
  body.description = markdownToDescription(body.description);
642
654
  }
@@ -670,6 +682,16 @@ function describeTask(res) {
670
682
  };
671
683
  }
672
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
+
673
695
  /**
674
696
  * Shared create handler for tasks (type=1) and notes (type=2).
675
697
  */
@@ -679,9 +701,13 @@ async function handleCreate(type, params, kindLabel) {
679
701
  body.name = name;
680
702
  body.type = type;
681
703
 
682
- // Resolve projectName -> projectId via the API. When omitted, OMIT the key
683
- // entirely so the backend applies the user's default project.
684
- if (!isBlankValue(projectName)) {
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.
685
711
  body.projectId = await resolveProjectId(projectName);
686
712
  }
687
713
 
@@ -703,6 +729,16 @@ async function handleEdit(params, kindLabel) {
703
729
  if (changed.length === 0) {
704
730
  return toolError('Nothing to update: provide at least one field to change.');
705
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
+ }
706
742
  const res = await api.updateTask(taskId, body);
707
743
  const { id, name: savedName } = describeTask(res);
708
744
  return toolOk(
@@ -807,6 +843,7 @@ server.tool(
807
843
  },
808
844
  async ({ taskId }) => {
809
845
  try {
846
+ await assertTaskInLock(taskId);
810
847
  const res = await api.completeTask(taskId);
811
848
  const { id, name } = describeTask(res);
812
849
  return toolOk(`Completed task "${name}" (id: ${id}).`);
@@ -825,6 +862,7 @@ server.tool(
825
862
  },
826
863
  async ({ taskId }) => {
827
864
  try {
865
+ await assertTaskInLock(taskId);
828
866
  await api.deleteTask(taskId);
829
867
  return toolOk(`Deleted task (id: ${taskId}).`);
830
868
  } catch (err) {
@@ -842,6 +880,7 @@ server.tool(
842
880
  },
843
881
  async ({ taskId }) => {
844
882
  try {
883
+ await assertTaskInLock(taskId);
845
884
  await api.deleteTask(taskId);
846
885
  return toolOk(`Deleted note (id: ${taskId}).`);
847
886
  } catch (err) {
@@ -884,12 +923,17 @@ function formatTags(tags) {
884
923
  /** Build the API filter params from tool params, resolving projectName. */
885
924
  async function buildListParams(type, params) {
886
925
  const { projectName, ...rest } = params;
887
- const out = { type };
888
- for (const [key, value] of Object.entries(rest)) {
889
- if (isBlankValue(value)) continue;
890
- out[key] = value;
891
- }
892
- if (!isBlankValue(projectName)) {
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.
893
937
  out.projectId = await resolveProjectId(projectName);
894
938
  }
895
939
  return out;
@@ -905,6 +949,8 @@ function summarizeItem(item, index) {
905
949
  const tagStr = formatTags(item.tags);
906
950
  if (tagStr) parts.push(`tags: ${tagStr}`);
907
951
  if (item.projectId) parts.push(`project: ${item.projectId}`);
952
+ const owner = ownerName(item.userId);
953
+ if (owner) parts.push(`owner: ${owner}`);
908
954
  return (
909
955
  `${index}. ${item.name || '(untitled)'} [id: ${item._id}]\n` +
910
956
  ` ${parts.join(' | ')}`
@@ -940,6 +986,8 @@ function renderItem(item, kindLabel) {
940
986
  const tagStr = formatTags(item.tags);
941
987
  if (tagStr) lines.push(`tags: ${tagStr}`);
942
988
  if (item.projectId) lines.push(`project: ${item.projectId}`);
989
+ const owner = ownerName(item.userId);
990
+ if (owner) lines.push(`owner: ${owner}`);
943
991
  if (item.parentId) lines.push(`parent: ${item.parentId}`);
944
992
  if (item.createdAt) lines.push(`created: ${item.createdAt}`);
945
993
  if (item.updatedAt) lines.push(`updated: ${item.updatedAt}`);
@@ -970,6 +1018,9 @@ const listProjectName = z
970
1018
  .optional()
971
1019
  .describe('Filter to a project by name (resolved to its id)');
972
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)');
973
1024
  const listTags = z.array(objectId).optional().describe('Filter by tag ids (AND — item must have all)');
974
1025
  const listSearch = z.string().optional().describe('Case-insensitive search on the name');
975
1026
  const listSortBy = sortBySchema.optional();
@@ -995,6 +1046,7 @@ server.tool(
995
1046
  .describe('Filter by priority: 1, 2, or 3'),
996
1047
  projectName: listProjectName,
997
1048
  projectId: listProjectId,
1049
+ assigneeId: listAssigneeId,
998
1050
  parentId: objectId.optional().describe('List subtasks of this parent task id'),
999
1051
  tags: listTags,
1000
1052
  search: listSearch,
@@ -1023,6 +1075,7 @@ server.tool(
1023
1075
  showCompleted: listShowCompleted,
1024
1076
  projectName: listProjectName,
1025
1077
  projectId: listProjectId,
1078
+ assigneeId: listAssigneeId,
1026
1079
  tags: listTags,
1027
1080
  search: listSearch,
1028
1081
  sortBy: listSortBy,
@@ -1050,7 +1103,9 @@ server.tool(
1050
1103
  async ({ taskId }) => {
1051
1104
  try {
1052
1105
  const res = await api.getTask(taskId);
1053
- return toolOk(renderItem(unwrapItem(res), 'task'));
1106
+ const item = unwrapItem(res);
1107
+ assertProjectAllowed(PROJECT_LOCK, item);
1108
+ return toolOk(renderItem(item, 'task'));
1054
1109
  } catch (err) {
1055
1110
  return toolError(`View task failed: ${err.message}`);
1056
1111
  }
@@ -1067,7 +1122,9 @@ server.tool(
1067
1122
  async ({ taskId }) => {
1068
1123
  try {
1069
1124
  const res = await api.getTask(taskId);
1070
- return toolOk(renderItem(unwrapItem(res), 'note'));
1125
+ const item = unwrapItem(res);
1126
+ assertProjectAllowed(PROJECT_LOCK, item);
1127
+ return toolOk(renderItem(item, 'note'));
1071
1128
  } catch (err) {
1072
1129
  return toolError(`View note failed: ${err.message}`);
1073
1130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "planko-mcp-server",
3
- "version": "0.4.1",
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
+ }
package/src/sanitize.js CHANGED
@@ -21,3 +21,17 @@ export function isBlankValue(value) {
21
21
  if (Array.isArray(value)) return value.length === 0;
22
22
  return false;
23
23
  }
24
+
25
+ /**
26
+ * LLM callers often put the date in `datePlain` and leave `dueDate` empty.
27
+ * The backend derives datePlain FROM dueDate but not the reverse, so a
28
+ * datePlain-only task ends up with dueDate=null and never appears on day/agenda
29
+ * views (it looks "not created"). Mirror datePlain -> dueDate in that case.
30
+ * Mutates and returns `body`.
31
+ */
32
+ export function applyDueDateFallback(body) {
33
+ if (isBlankValue(body.dueDate) && !isBlankValue(body.datePlain)) {
34
+ body.dueDate = body.datePlain;
35
+ }
36
+ return body;
37
+ }