@omnicoreos/planka-mcp 0.2.0 → 0.3.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.
Files changed (195) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +26 -0
  3. package/.claude-plugin/plugin.json +45 -0
  4. package/.codex-plugin/mcp.json +16 -0
  5. package/.codex-plugin/plugin.json +27 -0
  6. package/.mcp.json +17 -0
  7. package/CHANGELOG.md +510 -0
  8. package/README.es.md +294 -55
  9. package/README.md +293 -55
  10. package/dist/cli/init.d.ts +101 -0
  11. package/dist/cli/init.d.ts.map +1 -0
  12. package/dist/cli/init.js +481 -0
  13. package/dist/cli/init.js.map +1 -0
  14. package/dist/client.d.ts +32 -4
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +106 -32
  17. package/dist/client.js.map +1 -1
  18. package/dist/config/policy.d.ts +82 -0
  19. package/dist/config/policy.d.ts.map +1 -0
  20. package/dist/config/policy.js +226 -0
  21. package/dist/config/policy.js.map +1 -0
  22. package/dist/errors.d.ts +5 -1
  23. package/dist/errors.d.ts.map +1 -1
  24. package/dist/errors.js +62 -5
  25. package/dist/errors.js.map +1 -1
  26. package/dist/identity.generated.d.ts +2 -1
  27. package/dist/identity.generated.d.ts.map +1 -1
  28. package/dist/identity.generated.js +2 -1
  29. package/dist/identity.generated.js.map +1 -1
  30. package/dist/index.js +85 -10
  31. package/dist/index.js.map +1 -1
  32. package/dist/instructions.d.ts +21 -0
  33. package/dist/instructions.d.ts.map +1 -0
  34. package/dist/instructions.js +37 -0
  35. package/dist/instructions.js.map +1 -0
  36. package/dist/operations/actions.d.ts +654 -0
  37. package/dist/operations/actions.d.ts.map +1 -0
  38. package/dist/operations/actions.js +154 -0
  39. package/dist/operations/actions.js.map +1 -0
  40. package/dist/operations/archive.d.ts +28 -0
  41. package/dist/operations/archive.d.ts.map +1 -0
  42. package/dist/operations/archive.js +74 -0
  43. package/dist/operations/archive.js.map +1 -0
  44. package/dist/operations/attachments.d.ts +1 -1
  45. package/dist/operations/attachments.d.ts.map +1 -1
  46. package/dist/operations/attachments.js +3 -1
  47. package/dist/operations/attachments.js.map +1 -1
  48. package/dist/operations/board-id.d.ts +1 -1
  49. package/dist/operations/board-id.d.ts.map +1 -1
  50. package/dist/operations/board-id.js +13 -7
  51. package/dist/operations/board-id.js.map +1 -1
  52. package/dist/operations/boards.d.ts +96 -19
  53. package/dist/operations/boards.d.ts.map +1 -1
  54. package/dist/operations/boards.js +377 -93
  55. package/dist/operations/boards.js.map +1 -1
  56. package/dist/operations/card-brief.d.ts +91 -0
  57. package/dist/operations/card-brief.d.ts.map +1 -0
  58. package/dist/operations/card-brief.js +79 -0
  59. package/dist/operations/card-brief.js.map +1 -0
  60. package/dist/operations/cards.d.ts +34 -9
  61. package/dist/operations/cards.d.ts.map +1 -1
  62. package/dist/operations/cards.js +60 -14
  63. package/dist/operations/cards.js.map +1 -1
  64. package/dist/operations/comments.d.ts +61 -4
  65. package/dist/operations/comments.d.ts.map +1 -1
  66. package/dist/operations/comments.js +91 -8
  67. package/dist/operations/comments.js.map +1 -1
  68. package/dist/operations/duplicate.d.ts +16 -0
  69. package/dist/operations/duplicate.d.ts.map +1 -0
  70. package/dist/operations/duplicate.js +43 -0
  71. package/dist/operations/duplicate.js.map +1 -0
  72. package/dist/operations/labels.d.ts +1 -1
  73. package/dist/operations/labels.d.ts.map +1 -1
  74. package/dist/operations/labels.js +7 -4
  75. package/dist/operations/labels.js.map +1 -1
  76. package/dist/operations/lists.d.ts +63 -1
  77. package/dist/operations/lists.d.ts.map +1 -1
  78. package/dist/operations/lists.js +97 -2
  79. package/dist/operations/lists.js.map +1 -1
  80. package/dist/operations/members.d.ts +39 -0
  81. package/dist/operations/members.d.ts.map +1 -0
  82. package/dist/operations/members.js +107 -0
  83. package/dist/operations/members.js.map +1 -0
  84. package/dist/operations/projects.d.ts +16 -0
  85. package/dist/operations/projects.d.ts.map +1 -1
  86. package/dist/operations/projects.js +54 -9
  87. package/dist/operations/projects.js.map +1 -1
  88. package/dist/operations/tasks.d.ts +1 -1
  89. package/dist/operations/tasks.d.ts.map +1 -1
  90. package/dist/operations/tasks.js +5 -3
  91. package/dist/operations/tasks.js.map +1 -1
  92. package/dist/operations/users.d.ts +123 -0
  93. package/dist/operations/users.d.ts.map +1 -0
  94. package/dist/operations/users.js +180 -0
  95. package/dist/operations/users.js.map +1 -0
  96. package/dist/operations/verify.d.ts +84 -0
  97. package/dist/operations/verify.d.ts.map +1 -0
  98. package/dist/operations/verify.js +124 -0
  99. package/dist/operations/verify.js.map +1 -0
  100. package/dist/prompts.d.ts +48 -0
  101. package/dist/prompts.d.ts.map +1 -0
  102. package/dist/prompts.js +155 -0
  103. package/dist/prompts.js.map +1 -0
  104. package/dist/resources.d.ts +38 -0
  105. package/dist/resources.d.ts.map +1 -0
  106. package/dist/resources.js +127 -0
  107. package/dist/resources.js.map +1 -0
  108. package/dist/schemas/entities.d.ts +115 -24
  109. package/dist/schemas/entities.d.ts.map +1 -1
  110. package/dist/schemas/entities.js +48 -0
  111. package/dist/schemas/entities.js.map +1 -1
  112. package/dist/schemas/requests.d.ts +121 -46
  113. package/dist/schemas/requests.d.ts.map +1 -1
  114. package/dist/schemas/requests.js +57 -12
  115. package/dist/schemas/requests.js.map +1 -1
  116. package/dist/schemas/responses.d.ts +541 -186
  117. package/dist/schemas/responses.d.ts.map +1 -1
  118. package/dist/schemas/responses.js +13 -2
  119. package/dist/schemas/responses.js.map +1 -1
  120. package/dist/tools/activity.d.ts +150 -0
  121. package/dist/tools/activity.d.ts.map +1 -0
  122. package/dist/tools/activity.js +198 -0
  123. package/dist/tools/activity.js.map +1 -0
  124. package/dist/tools/annotations.d.ts +52 -0
  125. package/dist/tools/annotations.d.ts.map +1 -0
  126. package/dist/tools/annotations.js +214 -0
  127. package/dist/tools/annotations.js.map +1 -0
  128. package/dist/tools/attachments.d.ts +28 -4
  129. package/dist/tools/attachments.d.ts.map +1 -1
  130. package/dist/tools/attachments.js +53 -34
  131. package/dist/tools/attachments.js.map +1 -1
  132. package/dist/tools/card-ops.d.ts +232 -0
  133. package/dist/tools/card-ops.d.ts.map +1 -0
  134. package/dist/tools/card-ops.js +333 -0
  135. package/dist/tools/card-ops.js.map +1 -0
  136. package/dist/tools/cards.d.ts +90 -8
  137. package/dist/tools/cards.d.ts.map +1 -1
  138. package/dist/tools/cards.js +411 -128
  139. package/dist/tools/cards.js.map +1 -1
  140. package/dist/tools/comments.d.ts +226 -22
  141. package/dist/tools/comments.d.ts.map +1 -1
  142. package/dist/tools/comments.js +163 -134
  143. package/dist/tools/comments.js.map +1 -1
  144. package/dist/tools/dispatch.d.ts +47 -0
  145. package/dist/tools/dispatch.d.ts.map +1 -0
  146. package/dist/tools/dispatch.js +63 -0
  147. package/dist/tools/dispatch.js.map +1 -0
  148. package/dist/tools/guard.d.ts +9 -0
  149. package/dist/tools/guard.d.ts.map +1 -0
  150. package/dist/tools/guard.js +20 -0
  151. package/dist/tools/guard.js.map +1 -0
  152. package/dist/tools/index.d.ts +748 -450
  153. package/dist/tools/index.d.ts.map +1 -1
  154. package/dist/tools/index.js +136 -17
  155. package/dist/tools/index.js.map +1 -1
  156. package/dist/tools/labels.d.ts +213 -18
  157. package/dist/tools/labels.d.ts.map +1 -1
  158. package/dist/tools/labels.js +218 -203
  159. package/dist/tools/labels.js.map +1 -1
  160. package/dist/tools/lists.d.ts +222 -15
  161. package/dist/tools/lists.d.ts.map +1 -1
  162. package/dist/tools/lists.js +175 -156
  163. package/dist/tools/lists.js.map +1 -1
  164. package/dist/tools/members.d.ts +128 -0
  165. package/dist/tools/members.d.ts.map +1 -0
  166. package/dist/tools/members.js +150 -0
  167. package/dist/tools/members.js.map +1 -0
  168. package/dist/tools/navigation.d.ts +22 -2
  169. package/dist/tools/navigation.d.ts.map +1 -1
  170. package/dist/tools/navigation.js +60 -15
  171. package/dist/tools/navigation.js.map +1 -1
  172. package/dist/tools/queries.d.ts +196 -166
  173. package/dist/tools/queries.d.ts.map +1 -1
  174. package/dist/tools/queries.js +125 -155
  175. package/dist/tools/queries.js.map +1 -1
  176. package/dist/tools/tasks.d.ts +26 -6
  177. package/dist/tools/tasks.d.ts.map +1 -1
  178. package/dist/tools/tasks.js +110 -55
  179. package/dist/tools/tasks.js.map +1 -1
  180. package/dist/tools/users.d.ts +130 -0
  181. package/dist/tools/users.d.ts.map +1 -0
  182. package/dist/tools/users.js +165 -0
  183. package/dist/tools/users.js.map +1 -0
  184. package/docs/planka-2x-gotchas.md +121 -5
  185. package/docs/tools.md +771 -187
  186. package/docs/troubleshooting.md +137 -5
  187. package/hooks/hooks.json +15 -0
  188. package/hooks/preflight.mjs +100 -0
  189. package/package.json +6 -1
  190. package/scripts/setup.sh +8 -26
  191. package/scripts/sync-identity.mjs +55 -1
  192. package/server.json +87 -6
  193. package/tests/smoke/planka-smoke.mjs +512 -72
  194. package/workflow/skills/planka-close-card/SKILL.md +18 -5
  195. package/workflow/skills/planka-orchestrator/SKILL.md +36 -7
@@ -1,17 +1,47 @@
1
1
  /**
2
2
  * Card tools for PLANKA MCP server.
3
+ *
4
+ * Every write here re-reads the card and reports what LANDED — never what was
5
+ * asked for. See src/operations/verify.ts.
3
6
  */
4
- import { createCard, getCard, updateCard, moveCard, deleteCard, } from "../operations/cards.js";
7
+ import { createCard, getCardLight, getBoardNames, updateCard, moveCard, deleteCard, } from "../operations/cards.js";
8
+ import { getComments, COMMENTS_PAGE_SIZE } from "../operations/comments.js";
5
9
  import { createTasks } from "../operations/tasks.js";
6
10
  import { addLabelToCard } from "../operations/labels.js";
11
+ import { goneOn404, nothingToDo, verifiedDelete, verifiedWrite, writeResult, } from "../operations/verify.js";
7
12
  import { PlankaError } from "../errors.js";
13
+ import { assertListWritable, getPolicy } from "../config/policy.js";
14
+ /**
15
+ * PLANKA_PROTECTED_LIST_IDS has to protect a column's CONTENT, not just the
16
+ * column: emptying it card by card (move, archive, delete) was the way around
17
+ * it. Costs one extra read, and only on deployments that configured the
18
+ * variable — with no protected list there is nothing to check.
19
+ */
20
+ async function assertCardsListWritable(cardId, action) {
21
+ if (getPolicy().protectedListIds.length === 0)
22
+ return;
23
+ const before = await getCardLight({ cardId });
24
+ assertListWritable(before.card.listId, undefined, action);
25
+ }
26
+ /** Two date values are the same instant (or both empty). */
27
+ function sameDate(a, b) {
28
+ const na = a ?? null;
29
+ const nb = b ?? null;
30
+ if (na === nb)
31
+ return true;
32
+ if (na === null || nb === null)
33
+ return false;
34
+ const ta = Date.parse(na);
35
+ const tb = Date.parse(nb);
36
+ return Number.isNaN(ta) || Number.isNaN(tb) ? na === nb : ta === tb;
37
+ }
8
38
  /**
9
39
  * Tool: planka_create_card
10
40
  * Create a new card on a board.
11
41
  */
12
42
  export const createCardTool = {
13
43
  name: "planka_create_card",
14
- description: "Create a new card on a board. Optionally add tasks (checklist items) at the same time.",
44
+ description: "Create a card in a list, optionally with a checklist of tasks.",
15
45
  inputSchema: {
16
46
  type: "object",
17
47
  properties: {
@@ -46,52 +76,103 @@ export const createCardTool = {
46
76
  },
47
77
  handler: async (params) => {
48
78
  try {
49
- // Create the card
50
- const card = await createCard({
51
- listId: params.listId,
52
- name: params.name,
53
- description: params.description,
54
- dueDate: params.dueDate,
79
+ const requestedTasks = params.tasks ?? [];
80
+ const requestedLabels = params.labelIds ?? [];
81
+ // 1. Create the card and prove it exists.
82
+ const created = await verifiedWrite({
83
+ write: () => createCard({
84
+ listId: params.listId,
85
+ name: params.name,
86
+ description: params.description,
87
+ dueDate: params.dueDate,
88
+ }),
89
+ reread: (card) => getCardLight({ cardId: card.id }),
90
+ // The re-read is by id, so comparing ids alone is true by construction.
91
+ // What has to hold is what the CALL asked for: name and column.
92
+ landed: (after, card) => after.card.id === card.id &&
93
+ after.card.name === params.name &&
94
+ after.card.listId === params.listId,
55
95
  });
56
- // Add tasks if provided
57
- if (params.tasks && params.tasks.length > 0) {
58
- await createTasks({
59
- cardId: card.id,
60
- tasks: params.tasks.map((name) => ({ name })),
61
- });
96
+ const card = created.result;
97
+ const partialFailure = {};
98
+ // 2. Tasks. A failure here must never cost the caller the card id.
99
+ if (requestedTasks.length > 0) {
100
+ try {
101
+ await createTasks({
102
+ cardId: card.id,
103
+ tasks: requestedTasks.map((name) => ({ name })),
104
+ });
105
+ }
106
+ catch (error) {
107
+ partialFailure.tasks =
108
+ error instanceof Error ? error.message : String(error);
109
+ }
62
110
  }
63
- // Add labels if provided
64
- let labelsAttached = 0;
111
+ // 3. Labels, one by one: a bad labelId is a 404 that must not sink the rest.
65
112
  const labelErrors = [];
66
- if (params.labelIds && params.labelIds.length > 0) {
67
- for (const labelId of params.labelIds) {
68
- try {
69
- await addLabelToCard({ cardId: card.id, labelId });
70
- labelsAttached++;
71
- }
72
- catch (error) {
73
- // Track failed labels but continue
74
- labelErrors.push(labelId);
75
- }
113
+ for (const labelId of requestedLabels) {
114
+ try {
115
+ await addLabelToCard({ cardId: card.id, labelId });
116
+ }
117
+ catch (error) {
118
+ // A bad labelId and a 403 on the board are indistinguishable without
119
+ // the reason — and they need opposite reactions from the caller.
120
+ labelErrors.push({
121
+ labelId,
122
+ reason: error instanceof Error ? error.message : String(error),
123
+ });
124
+ }
125
+ }
126
+ // 4. Read the card once more and report REALITY.
127
+ let after = created.after;
128
+ let readError = created.verificationError;
129
+ if (requestedTasks.length > 0 || requestedLabels.length > 0) {
130
+ try {
131
+ after = await getCardLight({ cardId: card.id });
132
+ readError = undefined;
133
+ }
134
+ catch (error) {
135
+ readError = `the card was created but could not be re-read: ${error instanceof Error ? error.message : String(error)}`;
76
136
  }
77
137
  }
138
+ const tasksOnCard = after
139
+ ? requestedTasks.filter((name) => after.tasks.some((t) => t.name === name)).length
140
+ : 0;
141
+ const finalLabelIds = after
142
+ ? after.cardLabels.map((cl) => cl.labelId)
143
+ : [];
144
+ const labelsAttached = after
145
+ ? requestedLabels.filter((id) => finalLabelIds.includes(id)).length
146
+ : 0;
147
+ if (labelErrors.length > 0)
148
+ partialFailure.labelIds = labelErrors;
149
+ if (after && tasksOnCard < requestedTasks.length) {
150
+ partialFailure.tasksMissing = requestedTasks.filter((name) => !after.tasks.some((t) => t.name === name));
151
+ }
152
+ const allLabelsFailed = requestedLabels.length > 0 && labelsAttached === 0;
153
+ const ok = created.verified &&
154
+ !readError &&
155
+ Object.keys(partialFailure).length === 0 &&
156
+ !allLabelsFailed;
157
+ const body = {
158
+ success: ok,
159
+ verified: created.verified && !readError,
160
+ ...(readError ? { verificationError: readError } : {}),
161
+ card: {
162
+ id: card.id,
163
+ name: after?.card.name ?? card.name,
164
+ listId: after?.card.listId ?? card.listId,
165
+ },
166
+ tasksRequested: requestedTasks.length,
167
+ tasksCreated: tasksOnCard,
168
+ labelsRequested: requestedLabels.length,
169
+ labelsAttached,
170
+ ...(after ? { finalLabelIds } : {}),
171
+ ...(Object.keys(partialFailure).length > 0 ? { partialFailure } : {}),
172
+ };
78
173
  return {
79
- content: [
80
- {
81
- type: "text",
82
- text: JSON.stringify({
83
- success: true,
84
- card: {
85
- id: card.id,
86
- name: card.name,
87
- listId: card.listId,
88
- },
89
- tasksCreated: params.tasks?.length || 0,
90
- labelsAttached,
91
- ...(labelErrors.length > 0 && { labelErrors }),
92
- }, null, 2),
93
- },
94
- ],
174
+ content: [{ type: "text", text: JSON.stringify(body) }],
175
+ ...(ok ? {} : { isError: true }),
95
176
  };
96
177
  }
97
178
  catch (error) {
@@ -105,13 +186,104 @@ export const createCardTool = {
105
186
  }
106
187
  },
107
188
  };
189
+ /** Default size of the description preview in the digest. */
190
+ const DEFAULT_DESCRIPTION_CHARS = 200;
191
+ /** Ceiling for `descriptionChars`: past this, ask for detail: "full". */
192
+ const MAX_DESCRIPTION_CHARS = 2000;
193
+ /**
194
+ * Card labels resolved to names, keeping the ids the write tools need.
195
+ *
196
+ * The card response carries only the cardId<->labelId pairs; the names come
197
+ * from the board (see getBoardNames), so `names` may legitimately be missing.
198
+ */
199
+ function labelsOf(details, names) {
200
+ return details.cardLabels.map((cl) => ({
201
+ id: cl.labelId,
202
+ name: names?.get(cl.labelId) ??
203
+ details.labels.find((l) => l.id === cl.labelId)?.name ??
204
+ null,
205
+ }));
206
+ }
207
+ /** Assignees, from cardMemberships joined with the included users. */
208
+ function membersOf(details) {
209
+ return details.cardMemberships.map((m) => ({
210
+ id: m.userId,
211
+ name: details.users.find((u) => u.id === m.userId)?.name ?? null,
212
+ }));
213
+ }
214
+ /**
215
+ * Tasks grouped by their checklist — never flattened.
216
+ *
217
+ * A card with "Backend 3/3" and "QA 0/4" says something a flat 3/7 does not,
218
+ * and Planka 2.x models exactly that (taskLists -> tasks by taskListId).
219
+ */
220
+ function tasksOf(details, withItems) {
221
+ const lists = details.taskLists.map((tl) => {
222
+ const items = details.tasks.filter((t) => t.taskListId === tl.id);
223
+ return {
224
+ id: tl.id,
225
+ name: tl.name,
226
+ done: items.filter((t) => t.isCompleted).length,
227
+ total: items.length,
228
+ ...(withItems
229
+ ? {
230
+ items: items.map((t) => ({
231
+ id: t.id,
232
+ name: t.name,
233
+ isCompleted: t.isCompleted,
234
+ ...(t.assigneeUserId
235
+ ? {
236
+ assignee: details.users.find((u) => u.id === t.assigneeUserId)
237
+ ?.name ?? t.assigneeUserId,
238
+ }
239
+ : {}),
240
+ })),
241
+ }
242
+ : {}),
243
+ };
244
+ });
245
+ // Tasks whose checklist did not come back must not vanish from the count.
246
+ const known = new Set(details.taskLists.map((tl) => tl.id));
247
+ const orphans = details.tasks.filter((t) => !known.has(t.taskListId));
248
+ if (orphans.length > 0) {
249
+ lists.push({
250
+ id: null,
251
+ name: "(ungrouped)",
252
+ done: orphans.filter((t) => t.isCompleted).length,
253
+ total: orphans.length,
254
+ ...(withItems
255
+ ? {
256
+ items: orphans.map((t) => ({
257
+ id: t.id,
258
+ name: t.name,
259
+ isCompleted: t.isCompleted,
260
+ })),
261
+ }
262
+ : {}),
263
+ });
264
+ }
265
+ return {
266
+ done: details.tasks.filter((t) => t.isCompleted).length,
267
+ total: details.tasks.length,
268
+ lists,
269
+ };
270
+ }
271
+ /** Custom field values resolved to their field name (full detail only). */
272
+ function customFieldsOf(details) {
273
+ return details.customFieldValues.map((v) => ({
274
+ name: details.customFields.find((f) => f.id === v.customFieldId)?.name ?? null,
275
+ content: v.content ?? null,
276
+ }));
277
+ }
108
278
  /**
109
279
  * Tool: planka_get_card
110
- * Get full details of a card.
280
+ * A digest by default; the full record only when asked for.
111
281
  */
112
282
  export const getCardTool = {
113
283
  name: "planka_get_card",
114
- description: "Get full details of a card including tasks, comments, labels, and attachments.",
284
+ description: "One card as a digest: name, column, labels, members, checklist counts, attachment and comment counts, and the first 200 chars of the description. " +
285
+ 'Comments are NOT included by default — pass withComments (a number, or "all") to read them. ' +
286
+ 'detail:"full" adds the whole description, every task item, the attachment list and the custom fields.',
115
287
  inputSchema: {
116
288
  type: "object",
117
289
  properties: {
@@ -119,52 +291,133 @@ export const getCardTool = {
119
291
  type: "string",
120
292
  description: "The card ID",
121
293
  },
294
+ detail: {
295
+ type: "string",
296
+ enum: ["digest", "full"],
297
+ description: 'How much of the card to return. "digest" (default) is counts plus a description preview; "full" is the whole description, task items, attachments and custom fields.',
298
+ },
299
+ withComments: {
300
+ description: 'Comments to include: 0 (default, none), a number for the N most recent, or "all" to page through every comment.',
301
+ anyOf: [{ type: "number" }, { type: "string", enum: ["all"] }],
302
+ },
303
+ descriptionChars: {
304
+ type: "number",
305
+ description: 'Length of the description preview in the digest (default 200, max 2000). Ignored when detail is "full".',
306
+ },
122
307
  },
123
308
  required: ["cardId"],
124
309
  },
125
310
  handler: async (params) => {
126
311
  try {
127
- const details = await getCard({ cardId: params.cardId });
128
- const formatted = {
129
- card: {
130
- id: details.card.id,
131
- name: details.card.name,
132
- description: details.card.description,
133
- listId: details.card.listId,
134
- boardId: details.card.boardId,
135
- dueDate: details.card.dueDate,
136
- isCompleted: details.card.isCompleted,
137
- createdAt: details.card.createdAt,
138
- },
139
- tasks: details.tasks.map((t) => ({
140
- id: t.id,
141
- name: t.name,
142
- isCompleted: t.isCompleted,
143
- })),
144
- comments: details.comments.map((c) => ({
312
+ const full = params.detail === "full";
313
+ const wantAll = params.withComments === "all";
314
+ const wantN = typeof params.withComments === "number"
315
+ ? Math.max(0, Math.floor(params.withComments))
316
+ : 0;
317
+ const wantComments = wantAll || wantN > 0;
318
+ const [details, comments] = await Promise.all([
319
+ getCardLight({ cardId: params.cardId }),
320
+ wantComments
321
+ ? getComments({
322
+ cardId: params.cardId,
323
+ ...(wantAll
324
+ ? { all: true }
325
+ : { limit: Math.min(wantN, COMMENTS_PAGE_SIZE) }),
326
+ order: "asc",
327
+ })
328
+ : Promise.resolve(null),
329
+ ]);
330
+ // Names for the ids the card carries. Best effort: a board that cannot
331
+ // be read costs the names, never the card.
332
+ const names = await getBoardNames(details.card.boardId);
333
+ const listName = names?.lists.get(details.card.listId) ?? null;
334
+ const description = details.card.description ?? "";
335
+ const previewChars = Math.max(0, Math.min(params.descriptionChars ?? DEFAULT_DESCRIPTION_CHARS, MAX_DESCRIPTION_CHARS));
336
+ const preview = description.slice(0, previewChars);
337
+ const card = {
338
+ id: details.card.id,
339
+ name: details.card.name,
340
+ listId: details.card.listId,
341
+ // Always present, even as null: an omitted field cannot be told apart
342
+ // from "the board could not be read", and the model would assume the
343
+ // card has no column.
344
+ listName,
345
+ ...(listName === null
346
+ ? {
347
+ listNameUnavailable: names
348
+ ? "the board does not list this column — it may be the hidden archive or trash"
349
+ : "the board could not be read, so column and label names were not resolved",
350
+ }
351
+ : {}),
352
+ boardId: details.card.boardId,
353
+ dueDate: details.card.dueDate ?? null,
354
+ isDueCompleted: details.card.isDueCompleted ??
355
+ details.card.isDueDateCompleted ??
356
+ null,
357
+ isClosed: details.card.isClosed ?? null,
358
+ createdAt: details.card.createdAt,
359
+ updatedAt: details.card.updatedAt ?? null,
360
+ };
361
+ if (full) {
362
+ card.description = details.card.description ?? null;
363
+ }
364
+ else {
365
+ card.descriptionPreview = preview;
366
+ card.descriptionChars = description.length;
367
+ if (description.length > preview.length) {
368
+ card.descriptionTruncated = true;
369
+ }
370
+ }
371
+ // Planka's own counter, not something this server derived.
372
+ const commentsTotal = details.card.commentsTotal ?? null;
373
+ const body = {
374
+ detail: full ? "full" : "digest",
375
+ card,
376
+ labels: labelsOf(details, names?.labels),
377
+ members: membersOf(details),
378
+ tasks: tasksOf(details, full),
379
+ attachmentsCount: details.attachments.length,
380
+ commentsTotal,
381
+ };
382
+ if (full) {
383
+ body.attachments = details.attachments.map((a) => ({
384
+ id: a.id,
385
+ name: a.name,
386
+ type: a.type ?? null,
387
+ mimeType: a.data?.mimeType ?? null,
388
+ sizeBytes: a.data?.size ?? null,
389
+ }));
390
+ body.customFields = customFieldsOf(details);
391
+ }
392
+ if (comments) {
393
+ body.comments = comments.comments.map((c) => ({
145
394
  id: c.id,
146
395
  text: c.text,
147
396
  author: c.authorName,
148
397
  createdAt: c.createdAt,
149
- })),
150
- labels: details.cardLabels.map((cl) => {
151
- const label = details.labels.find((l) => l.id === cl.labelId);
152
- return {
153
- id: cl.labelId,
154
- name: label?.name,
155
- color: label?.color,
156
- };
157
- }),
158
- attachments: details.attachments.map((a) => ({
159
- id: a.id,
160
- name: a.name,
161
- })),
162
- };
398
+ }));
399
+ body.commentsReturned = comments.comments.length;
400
+ body.commentsOrder = "oldest-first";
401
+ if (comments.hasMore)
402
+ body.commentsHasMore = true;
403
+ if (comments.nextBeforeId)
404
+ body.nextBeforeId = comments.nextBeforeId;
405
+ if (comments.truncated)
406
+ body.commentsTruncated = true;
407
+ }
408
+ else if (commentsTotal !== 0) {
409
+ body.comments =
410
+ 'not read — pass withComments (a number, or "all"), or call planka_get_comments';
411
+ }
412
+ if (!full) {
413
+ body.more =
414
+ 'detail:"full" for the whole description, task items, attachments and custom fields';
415
+ }
163
416
  return {
164
417
  content: [
165
418
  {
166
419
  type: "text",
167
- text: JSON.stringify(formatted, null, 2),
420
+ text: JSON.stringify(body),
168
421
  },
169
422
  ],
170
423
  };
@@ -186,7 +439,7 @@ export const getCardTool = {
186
439
  */
187
440
  export const updateCardTool = {
188
441
  name: "planka_update_card",
189
- description: "Update a card's properties (name, description, due date, completion status).",
442
+ description: "Update a card's name, description, due date or completion. Null clears a field.",
190
443
  inputSchema: {
191
444
  type: "object",
192
445
  properties: {
@@ -226,24 +479,41 @@ export const updateCardTool = {
226
479
  patch.dueDate = updates.dueDate;
227
480
  if (updates.isCompleted !== undefined)
228
481
  patch.isCompleted = updates.isCompleted;
229
- const card = await updateCard(patch);
230
- return {
231
- content: [
232
- {
233
- type: "text",
234
- text: JSON.stringify({
235
- success: true,
236
- card: {
237
- id: card.id,
238
- name: card.name,
239
- description: card.description,
240
- dueDate: card.dueDate,
241
- isCompleted: card.isCompleted,
242
- },
243
- }, null, 2),
244
- },
245
- ],
482
+ if (Object.keys(patch).length === 1) {
483
+ return nothingToDo("name, description, dueDate, isCompleted");
484
+ }
485
+ const mismatches = (after) => {
486
+ const bad = [];
487
+ const c = after.card;
488
+ if (patch.name !== undefined && c.name !== patch.name)
489
+ bad.push("name");
490
+ if (patch.description !== undefined &&
491
+ (c.description ?? null) !== (patch.description ?? null))
492
+ bad.push("description");
493
+ if (patch.dueDate !== undefined && !sameDate(c.dueDate, patch.dueDate))
494
+ bad.push("dueDate");
495
+ if (patch.isCompleted !== undefined &&
496
+ c.isCompleted !== patch.isCompleted)
497
+ bad.push("isCompleted");
498
+ return bad;
246
499
  };
500
+ const written = await verifiedWrite({
501
+ write: () => updateCard(patch),
502
+ reread: () => getCardLight({ cardId }),
503
+ landed: (after) => mismatches(after).length === 0,
504
+ });
505
+ const after = written.after;
506
+ const notLanded = after ? mismatches(after) : [];
507
+ return writeResult({
508
+ card: {
509
+ id: written.result.id,
510
+ name: after?.card.name ?? written.result.name,
511
+ description: after?.card.description ?? written.result.description,
512
+ dueDate: after?.card.dueDate ?? written.result.dueDate,
513
+ isCompleted: after?.card.isCompleted ?? written.result.isCompleted,
514
+ },
515
+ ...(notLanded.length > 0 ? { notLanded } : {}),
516
+ }, written);
247
517
  }
248
518
  catch (error) {
249
519
  if (error instanceof PlankaError) {
@@ -262,7 +532,7 @@ export const updateCardTool = {
262
532
  */
263
533
  export const moveCardTool = {
264
534
  name: "planka_move_card",
265
- description: "Move a card to a different list or position. Use this for workflow transitions (e.g., 'To Do' -> 'In Progress').",
535
+ description: "Move a card to another list, board or position the workflow transition.",
266
536
  inputSchema: {
267
537
  type: "object",
268
538
  properties: {
@@ -274,6 +544,10 @@ export const moveCardTool = {
274
544
  type: "string",
275
545
  description: "Target list ID",
276
546
  },
547
+ boardId: {
548
+ type: "string",
549
+ description: "Target board ID — only when moving across boards (listId must belong to it)",
550
+ },
277
551
  position: {
278
552
  type: "number",
279
553
  description: "Position in the list (lower = higher). Default: end of list",
@@ -283,26 +557,36 @@ export const moveCardTool = {
283
557
  },
284
558
  handler: async (params) => {
285
559
  try {
286
- const card = await moveCard({
287
- cardId: params.cardId,
288
- listId: params.listId,
289
- position: params.position ?? 65536,
560
+ await assertCardsListWritable(params.cardId, "move cards out of");
561
+ const written = await verifiedWrite({
562
+ write: () => moveCard({
563
+ cardId: params.cardId,
564
+ listId: params.listId,
565
+ boardId: params.boardId,
566
+ position: params.position ?? 65536,
567
+ }),
568
+ reread: () => getCardLight({ cardId: params.cardId }),
569
+ landed: (after) => after.card.listId === params.listId &&
570
+ (params.boardId ? after.card.boardId === params.boardId : true),
290
571
  });
291
- return {
292
- content: [
293
- {
294
- type: "text",
295
- text: JSON.stringify({
296
- success: true,
297
- card: {
298
- id: card.id,
299
- name: card.name,
300
- listId: card.listId,
301
- },
302
- }, null, 2),
303
- },
304
- ],
305
- };
572
+ const after = written.after;
573
+ return writeResult({
574
+ card: {
575
+ id: written.result.id,
576
+ name: after?.card.name ?? written.result.name,
577
+ listId: after?.card.listId ?? written.result.listId,
578
+ boardId: after?.card.boardId ?? written.result.boardId,
579
+ },
580
+ requestedListId: params.listId,
581
+ ...(params.boardId ? { requestedBoardId: params.boardId } : {}),
582
+ ...(after && after.card.listId !== params.listId
583
+ ? {
584
+ notLanded: "the card is still in list " +
585
+ after.card.listId +
586
+ " — check that the listId belongs to the card's board",
587
+ }
588
+ : {}),
589
+ }, written);
306
590
  }
307
591
  catch (error) {
308
592
  if (error instanceof PlankaError) {
@@ -321,7 +605,7 @@ export const moveCardTool = {
321
605
  */
322
606
  export const deleteCardTool = {
323
607
  name: "planka_delete_card",
324
- description: "Permanently delete a card. This cannot be undone.",
608
+ description: "Permanently delete a card with its tasks, comments and attachments. Cannot be undone; confirmed by re-reading.",
325
609
  inputSchema: {
326
610
  type: "object",
327
611
  properties: {
@@ -334,18 +618,17 @@ export const deleteCardTool = {
334
618
  },
335
619
  handler: async (params) => {
336
620
  try {
337
- await deleteCard({ cardId: params.cardId });
338
- return {
339
- content: [
340
- {
341
- type: "text",
342
- text: JSON.stringify({
343
- success: true,
344
- message: `Card ${params.cardId} deleted`,
345
- }, null, 2),
346
- },
347
- ],
348
- };
621
+ await assertCardsListWritable(params.cardId, "delete cards from");
622
+ const deleted = await verifiedDelete({
623
+ del: () => deleteCard({ cardId: params.cardId }),
624
+ confirmGone: () => goneOn404(() => getCardLight({ cardId: params.cardId })),
625
+ });
626
+ return writeResult({
627
+ cardId: params.cardId,
628
+ message: deleted.verified
629
+ ? `Card ${params.cardId} deleted`
630
+ : `Card ${params.cardId} still answers a read after the delete`,
631
+ }, deleted);
349
632
  }
350
633
  catch (error) {
351
634
  if (error instanceof PlankaError) {