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 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.2.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
- // Resolve projectName -> projectId via the API. When omitted, OMIT the key
684
- // entirely so the backend applies the user's default project.
685
- 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.
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
- const out = { type };
889
- for (const [key, value] of Object.entries(rest)) {
890
- if (isBlankValue(value)) continue;
891
- out[key] = value;
892
- }
893
- 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.
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
- return toolOk(renderItem(unwrapItem(res), 'task'));
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
- return toolOk(renderItem(unwrapItem(res), 'note'));
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.4.2",
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
+ }