@omnicoreos/planka-mcp 0.2.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 (126) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/CONTRIBUTING.md +37 -0
  3. package/CREDITS.md +39 -0
  4. package/LICENSE +22 -0
  5. package/README.es.md +213 -0
  6. package/README.md +213 -0
  7. package/dist/client.d.ts +96 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +281 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/errors.d.ts +85 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +226 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/identity.generated.d.ts +3 -0
  16. package/dist/identity.generated.d.ts.map +1 -0
  17. package/dist/identity.generated.js +4 -0
  18. package/dist/identity.generated.js.map +1 -0
  19. package/dist/index.d.ts +3 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +93 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/operations/attachments.d.ts +34 -0
  24. package/dist/operations/attachments.d.ts.map +1 -0
  25. package/dist/operations/attachments.js +89 -0
  26. package/dist/operations/attachments.js.map +1 -0
  27. package/dist/operations/board-id.d.ts +9 -0
  28. package/dist/operations/board-id.d.ts.map +1 -0
  29. package/dist/operations/board-id.js +86 -0
  30. package/dist/operations/board-id.js.map +1 -0
  31. package/dist/operations/boards.d.ts +143 -0
  32. package/dist/operations/boards.d.ts.map +1 -0
  33. package/dist/operations/boards.js +391 -0
  34. package/dist/operations/boards.js.map +1 -0
  35. package/dist/operations/cards.d.ts +36 -0
  36. package/dist/operations/cards.d.ts.map +1 -0
  37. package/dist/operations/cards.js +81 -0
  38. package/dist/operations/cards.js.map +1 -0
  39. package/dist/operations/comments.d.ts +28 -0
  40. package/dist/operations/comments.d.ts.map +1 -0
  41. package/dist/operations/comments.js +53 -0
  42. package/dist/operations/comments.js.map +1 -0
  43. package/dist/operations/labels.d.ts +60 -0
  44. package/dist/operations/labels.d.ts.map +1 -0
  45. package/dist/operations/labels.js +146 -0
  46. package/dist/operations/labels.js.map +1 -0
  47. package/dist/operations/lists.d.ts +15 -0
  48. package/dist/operations/lists.d.ts.map +1 -0
  49. package/dist/operations/lists.js +40 -0
  50. package/dist/operations/lists.js.map +1 -0
  51. package/dist/operations/projects.d.ts +24 -0
  52. package/dist/operations/projects.d.ts.map +1 -0
  53. package/dist/operations/projects.js +61 -0
  54. package/dist/operations/projects.js.map +1 -0
  55. package/dist/operations/tasks.d.ts +30 -0
  56. package/dist/operations/tasks.d.ts.map +1 -0
  57. package/dist/operations/tasks.js +111 -0
  58. package/dist/operations/tasks.js.map +1 -0
  59. package/dist/schemas/entities.d.ts +389 -0
  60. package/dist/schemas/entities.d.ts.map +1 -0
  61. package/dist/schemas/entities.js +202 -0
  62. package/dist/schemas/entities.js.map +1 -0
  63. package/dist/schemas/requests.d.ts +539 -0
  64. package/dist/schemas/requests.d.ts.map +1 -0
  65. package/dist/schemas/requests.js +187 -0
  66. package/dist/schemas/requests.js.map +1 -0
  67. package/dist/schemas/responses.d.ts +2626 -0
  68. package/dist/schemas/responses.d.ts.map +1 -0
  69. package/dist/schemas/responses.js +75 -0
  70. package/dist/schemas/responses.js.map +1 -0
  71. package/dist/tools/attachments.d.ts +270 -0
  72. package/dist/tools/attachments.d.ts.map +1 -0
  73. package/dist/tools/attachments.js +247 -0
  74. package/dist/tools/attachments.js.map +1 -0
  75. package/dist/tools/cards.d.ts +401 -0
  76. package/dist/tools/cards.d.ts.map +1 -0
  77. package/dist/tools/cards.js +368 -0
  78. package/dist/tools/cards.js.map +1 -0
  79. package/dist/tools/comments.d.ts +217 -0
  80. package/dist/tools/comments.d.ts.map +1 -0
  81. package/dist/tools/comments.js +208 -0
  82. package/dist/tools/comments.js.map +1 -0
  83. package/dist/tools/index.d.ts +1293 -0
  84. package/dist/tools/index.d.ts.map +1 -0
  85. package/dist/tools/index.js +62 -0
  86. package/dist/tools/index.js.map +1 -0
  87. package/dist/tools/labels.d.ts +214 -0
  88. package/dist/tools/labels.d.ts.map +1 -0
  89. package/dist/tools/labels.js +302 -0
  90. package/dist/tools/labels.js.map +1 -0
  91. package/dist/tools/lists.d.ts +117 -0
  92. package/dist/tools/lists.d.ts.map +1 -0
  93. package/dist/tools/lists.js +178 -0
  94. package/dist/tools/lists.js.map +1 -0
  95. package/dist/tools/navigation.d.ts +106 -0
  96. package/dist/tools/navigation.d.ts.map +1 -0
  97. package/dist/tools/navigation.js +151 -0
  98. package/dist/tools/navigation.js.map +1 -0
  99. package/dist/tools/queries.d.ts +380 -0
  100. package/dist/tools/queries.d.ts.map +1 -0
  101. package/dist/tools/queries.js +256 -0
  102. package/dist/tools/queries.js.map +1 -0
  103. package/dist/tools/tasks.d.ts +223 -0
  104. package/dist/tools/tasks.d.ts.map +1 -0
  105. package/dist/tools/tasks.js +169 -0
  106. package/dist/tools/tasks.js.map +1 -0
  107. package/docs/planka-2x-gotchas.md +192 -0
  108. package/docs/tools.md +659 -0
  109. package/docs/troubleshooting.md +138 -0
  110. package/package.json +71 -0
  111. package/project.identity.json +7 -0
  112. package/scripts/bootstrap-board.mjs +160 -0
  113. package/scripts/lib/planka-api.mjs +244 -0
  114. package/scripts/lib/workflow-template.mjs +26 -0
  115. package/scripts/setup.mjs +236 -0
  116. package/scripts/setup.sh +48 -0
  117. package/scripts/sync-identity.mjs +57 -0
  118. package/server.json +43 -0
  119. package/tests/smoke/planka-smoke.mjs +920 -0
  120. package/workflow/README.md +232 -0
  121. package/workflow/board-template.md +115 -0
  122. package/workflow/skills/planka-close-card/SKILL.md +81 -0
  123. package/workflow/skills/planka-orchestrator/SKILL.md +126 -0
  124. package/workflow/worktrees/README.md +104 -0
  125. package/workflow/worktrees/wt.conf.example +24 -0
  126. package/workflow/worktrees/wt.sh +345 -0
@@ -0,0 +1,920 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * End-to-end smoke test for the Planka MCP server.
5
+ *
6
+ * It speaks MCP over stdio against the built server, so it exercises the real
7
+ * thing — dispatch, schemas, error formatting — not individual functions. Every
8
+ * MCP write is then cross-checked against the raw Planka HTTP API: if a tool
9
+ * reports success on something that never landed, this is where it shows.
10
+ *
11
+ * Opt-in. Configure it with the same credentials your MCP client uses:
12
+ *
13
+ * PLANKA_BASE_URL, PLANKA_AGENT_EMAIL, PLANKA_AGENT_PASSWORD
14
+ * PLANKA_SMOKE_BOARD_ID board to run against (required)
15
+ *
16
+ * Optional:
17
+ * PLANKA_SMOKE_LIST_ID list where the scratch card is created (otherwise a
18
+ * scratch-looking column is picked, else the first)
19
+ * PLANKA_MCP_ENTRY server entry point (default dist/index.js)
20
+ * PLANKA_SMOKE_FAIL_AFTER=<n> inject a failure after check n, to prove that
21
+ * cleanup still runs
22
+ * VERBOSE=1 print each check's payload
23
+ *
24
+ * Everything it creates is deleted in a `finally` — and on SIGINT/SIGTERM too.
25
+ * Cleanup goes over raw HTTP on purpose: when a signal kills this process the
26
+ * MCP server child dies with it and its tools stop answering, so the safety net
27
+ * cannot depend on the thing under test.
28
+ *
29
+ * Exit codes: 0 green · 1 a check failed · 2 configuration problem.
30
+ */
31
+
32
+ import { createHash } from "node:crypto";
33
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
34
+ import { tmpdir } from "node:os";
35
+ import { join, resolve } from "node:path";
36
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
37
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
38
+ import { PlankaApi, boardEntities } from "../../scripts/lib/planka-api.mjs";
39
+
40
+ const EXPECTED_TOOL_COUNT = 24;
41
+
42
+ /** Prefix on every artifact this test creates; the initial sweep looks for it. */
43
+ const PREFIX = "[smoke planka-mcp]";
44
+ const CARD_NAME = `${PREFIX} delete me if I survived`;
45
+
46
+ const required = [
47
+ "PLANKA_BASE_URL",
48
+ "PLANKA_AGENT_EMAIL",
49
+ "PLANKA_AGENT_PASSWORD",
50
+ "PLANKA_SMOKE_BOARD_ID",
51
+ ];
52
+ const missing = required.filter((name) => !process.env[name]);
53
+ if (missing.length) {
54
+ console.error(`Smoke test skipped: missing ${missing.join(", ")}`);
55
+ console.error("This test is opt-in and requires a writable real Planka board.");
56
+ process.exit(2);
57
+ }
58
+
59
+ const boardId = process.env.PLANKA_SMOKE_BOARD_ID;
60
+ const entry = resolve(process.env.PLANKA_MCP_ENTRY || "dist/index.js");
61
+ const failAfter = process.env.PLANKA_SMOKE_FAIL_AFTER
62
+ ? Number(process.env.PLANKA_SMOKE_FAIL_AFTER)
63
+ : null;
64
+
65
+ const api = await PlankaApi.fromCredentials({
66
+ baseUrl: process.env.PLANKA_BASE_URL,
67
+ email: process.env.PLANKA_AGENT_EMAIL,
68
+ password: process.env.PLANKA_AGENT_PASSWORD,
69
+ });
70
+
71
+ const client = new Client({ name: "planka-mcp-smoke", version: "0.1.0" });
72
+ const transport = new StdioClientTransport({
73
+ command: process.execPath,
74
+ args: [entry],
75
+ env: {
76
+ PLANKA_BASE_URL: process.env.PLANKA_BASE_URL,
77
+ PLANKA_AGENT_EMAIL: process.env.PLANKA_AGENT_EMAIL,
78
+ PLANKA_AGENT_PASSWORD: process.env.PLANKA_AGENT_PASSWORD,
79
+ },
80
+ stderr: "pipe",
81
+ });
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Check bookkeeping
85
+ // ---------------------------------------------------------------------------
86
+
87
+ let passed = 0;
88
+ let failed = 0;
89
+ let checkIndex = 0;
90
+ /** Set when a signal arrives: stop counting checks and only clean up. */
91
+ let aborting = false;
92
+
93
+ function check(label, ok, detail) {
94
+ if (aborting) return ok;
95
+ checkIndex++;
96
+ if (ok) passed++;
97
+ else failed++;
98
+ console.log(
99
+ `${ok ? "PASS" : "FAIL"} ${String(checkIndex).padStart(2, " ")}. ${label}`
100
+ );
101
+ if ((!ok || process.env.VERBOSE) && detail !== undefined) {
102
+ console.log(String(detail).slice(0, 1200).replace(/^/gm, " "));
103
+ }
104
+ if (failAfter !== null && checkIndex === failAfter) {
105
+ throw new Error(
106
+ `PLANKA_SMOKE_FAIL_AFTER=${failAfter} — failure injected on purpose`
107
+ );
108
+ }
109
+ return ok;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Artifacts and cleanup (raw HTTP, never the tools under test)
114
+ // ---------------------------------------------------------------------------
115
+
116
+ const artifacts = { attachments: [], cards: [], lists: [], labels: [] };
117
+ let cleanedUp = false;
118
+ let workDir = null;
119
+
120
+ const forget = (kind, id) => {
121
+ artifacts[kind] = artifacts[kind].filter((item) => item !== id);
122
+ };
123
+
124
+ async function tryDelete(kind, id, remove) {
125
+ try {
126
+ await remove(id);
127
+ console.log(` cleanup: ${kind} ${id} deleted`);
128
+ } catch (error) {
129
+ console.log(` cleanup: ${kind} ${id} NOT deleted — ${error.message}`);
130
+ }
131
+ }
132
+
133
+ async function cleanup() {
134
+ if (cleanedUp) return;
135
+ cleanedUp = true;
136
+ const pending =
137
+ artifacts.attachments.length +
138
+ artifacts.cards.length +
139
+ artifacts.lists.length +
140
+ artifacts.labels.length;
141
+ console.log("\n--- cleanup ---");
142
+ if (pending === 0) console.log(" nothing to delete");
143
+
144
+ // Attachments go with their card, but if the card survived they must go too.
145
+ for (const id of artifacts.attachments.splice(0)) {
146
+ await tryDelete("attachment", id, (x) => api.deleteAttachment(x));
147
+ }
148
+ for (const id of artifacts.cards.splice(0)) {
149
+ await tryDelete("card", id, (x) => api.deleteCard(x));
150
+ }
151
+ for (const id of artifacts.lists.splice(0)) {
152
+ await tryDelete("list", id, (x) => api.deleteList(x));
153
+ }
154
+ for (const id of artifacts.labels.splice(0)) {
155
+ await tryDelete("label", id, (x) => api.deleteLabel(x));
156
+ }
157
+
158
+ if (workDir) {
159
+ try {
160
+ rmSync(workDir, { recursive: true, force: true });
161
+ } catch {
162
+ // nothing else to do about a temp dir
163
+ }
164
+ }
165
+ }
166
+
167
+ for (const signal of ["SIGINT", "SIGTERM"]) {
168
+ process.on(signal, async () => {
169
+ if (aborting) process.exit(130);
170
+ aborting = true;
171
+ console.log(`\n${signal} — cleaning up before exiting…`);
172
+ await client.close().catch(() => {});
173
+ await cleanup();
174
+ process.exit(130);
175
+ });
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // MCP helpers
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /** Calls a tool and returns { text, isError, content } without throwing. */
183
+ async function callTool(name, args) {
184
+ let result;
185
+ try {
186
+ result = await client.callTool({ name, arguments: args });
187
+ } catch (error) {
188
+ return { text: error instanceof Error ? error.message : String(error), isError: true, content: [] };
189
+ }
190
+ const content = result.content ?? [];
191
+ const text = content
192
+ .map((item) => item.text ?? `[${item.type} ${item.mimeType ?? ""}]`)
193
+ .join("\n");
194
+ return { text, isError: result.isError ?? false, content };
195
+ }
196
+
197
+ /** Calls a tool that must succeed and returns its parsed JSON payload. */
198
+ async function json(name, args) {
199
+ const result = await callTool(name, args);
200
+ if (result.isError) return null;
201
+ try {
202
+ return JSON.parse(result.text);
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ const rawBoard = async () => boardEntities(await api.getBoard(boardId));
209
+
210
+ /** Picks the list to work in: explicit id, a scratch-looking column, or the first. */
211
+ function resolveList(lists) {
212
+ const wanted = process.env.PLANKA_SMOKE_LIST_ID;
213
+ if (wanted) {
214
+ const list = lists.find((item) => item.id === wanted);
215
+ if (!list) {
216
+ throw new Error(
217
+ `PLANKA_SMOKE_LIST_ID=${wanted} is not a list of board ${boardId}`
218
+ );
219
+ }
220
+ return { list, how: "PLANKA_SMOKE_LIST_ID" };
221
+ }
222
+ const usable = lists.filter((item) => item.name && item.type !== "closed");
223
+ if (usable.length === 0) throw new Error("The board needs at least one active list");
224
+ const scratch = usable.find((item) =>
225
+ /evaluando|scratch|sandbox|smoke/i.test(item.name)
226
+ );
227
+ if (scratch) return { list: scratch, how: "scratch column matched by name" };
228
+ console.log(
229
+ ` ⚠ No scratch column found. Using "${usable[0].name}". ` +
230
+ "Set PLANKA_SMOKE_LIST_ID to pick another."
231
+ );
232
+ return { list: usable[0], how: "first active list" };
233
+ }
234
+
235
+ /** Deletes leftovers from a previous run that died halfway through. */
236
+ async function sweepResidue(state) {
237
+ let swept = 0;
238
+ for (const card of state.cards) {
239
+ if (card.name?.startsWith(PREFIX)) {
240
+ await api.deleteCard(card.id).catch(() => {});
241
+ swept++;
242
+ }
243
+ }
244
+ for (const list of state.lists) {
245
+ if (list.name?.startsWith(PREFIX)) {
246
+ await api.deleteList(list.id).catch(() => {});
247
+ swept++;
248
+ }
249
+ }
250
+ for (const label of state.labels) {
251
+ if (label.name?.startsWith(PREFIX)) {
252
+ await api.deleteLabel(label.id).catch(() => {});
253
+ swept++;
254
+ }
255
+ }
256
+ console.log(swept === 0 ? " sweep: no residue" : ` sweep: ${swept} artifact(s)`);
257
+ return swept;
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // The smoke
262
+ // ---------------------------------------------------------------------------
263
+
264
+ async function run() {
265
+ const tools = await client.listTools();
266
+ check(
267
+ `tools/list publishes ${EXPECTED_TOOL_COUNT} tools`,
268
+ tools.tools.length === EXPECTED_TOOL_COUNT,
269
+ tools.tools.map((tool) => tool.name).join(", ")
270
+ );
271
+
272
+ let state = await rawBoard();
273
+ await sweepResidue(state);
274
+ state = await rawBoard();
275
+
276
+ const { list, how } = resolveList(state.lists);
277
+ console.log(
278
+ `\nTarget: board "${state.board?.name}" (${boardId}) · list "${list.name}" (${list.id}) — ${how}\n`
279
+ );
280
+
281
+ const mcpBoard = await json("planka_get_board", { boardId });
282
+ check(
283
+ "planka_get_board returns the selected board",
284
+ mcpBoard?.board?.id === boardId,
285
+ mcpBoard?.board
286
+ );
287
+ const boardText = JSON.stringify(mcpBoard);
288
+
289
+ // --- labels the scratch card will carry -----------------------------------
290
+ const labelA = await json("planka_manage_labels", {
291
+ action: "create",
292
+ boardId,
293
+ name: `${PREFIX} A`,
294
+ color: "berry-red",
295
+ });
296
+ if (labelA?.label?.id) artifacts.labels.push(labelA.label.id);
297
+ check("planka_manage_labels create (A)", Boolean(labelA?.label?.id), labelA);
298
+
299
+ const labelB = await json("planka_manage_labels", {
300
+ action: "create",
301
+ boardId,
302
+ name: `${PREFIX} B`,
303
+ color: "lagoon-blue",
304
+ });
305
+ if (labelB?.label?.id) artifacts.labels.push(labelB.label.id);
306
+ check("planka_manage_labels create (B)", Boolean(labelB?.label?.id), labelB);
307
+
308
+ if (!labelA?.label?.id || !labelB?.label?.id) {
309
+ throw new Error("Without labels there is no smoke — stopping here");
310
+ }
311
+
312
+ const renamed = await json("planka_manage_labels", {
313
+ action: "update",
314
+ labelId: labelA.label.id,
315
+ name: `${PREFIX} A2`,
316
+ color: "pumpkin-orange",
317
+ });
318
+ check(
319
+ "planka_manage_labels update renames and recolors",
320
+ renamed?.label?.name === `${PREFIX} A2`,
321
+ renamed
322
+ );
323
+
324
+ // --- the scratch card -----------------------------------------------------
325
+ const created = await json("planka_create_card", {
326
+ listId: list.id,
327
+ name: CARD_NAME,
328
+ description: "Scratch card of the planka-mcp smoke test. Delete it if you see it.",
329
+ tasks: ["first", "second"],
330
+ labelIds: [labelA.label.id],
331
+ });
332
+ const cardId = created?.card?.id;
333
+ if (cardId) artifacts.cards.push(cardId);
334
+ check(
335
+ "planka_create_card creates a card with 2 tasks and 1 label",
336
+ Boolean(cardId) && created.tasksCreated === 2 && created.labelsAttached === 1,
337
+ created
338
+ );
339
+ if (!cardId) throw new Error("Without a card there is no smoke — stopping here");
340
+
341
+ state = await rawBoard();
342
+ check(
343
+ "the raw API confirms the card exists",
344
+ state.cards.some((item) => item.id === cardId),
345
+ cardId
346
+ );
347
+
348
+ const details = await json("planka_get_card", { cardId });
349
+ check(
350
+ "planka_get_card returns the name, both tasks and the label",
351
+ details?.card?.name === CARD_NAME &&
352
+ details?.tasks?.length === 2 &&
353
+ details?.labels?.length === 1,
354
+ details
355
+ );
356
+ const taskId = details?.tasks?.[0]?.id;
357
+
358
+ const updated = await callTool("planka_update_card", {
359
+ cardId,
360
+ description: "description updated by the smoke test",
361
+ });
362
+ check("planka_update_card updates the description", !updated.isError, updated.text);
363
+
364
+ const reread = await json("planka_get_card", { cardId });
365
+ check(
366
+ "the update persisted (verified by re-reading)",
367
+ reread?.card?.description === "description updated by the smoke test",
368
+ reread?.card?.description
369
+ );
370
+
371
+ const taskUpdated = await callTool("planka_update_task", {
372
+ taskId,
373
+ isCompleted: true,
374
+ name: "first (done)",
375
+ });
376
+ check("planka_update_task completes and renames", !taskUpdated.isError, taskUpdated.text);
377
+
378
+ const taskAdded = await callTool("planka_create_tasks", {
379
+ cardId,
380
+ tasks: ["third"],
381
+ });
382
+ check("planka_create_tasks appends a task", !taskAdded.isError, taskAdded.text);
383
+
384
+ const taskDeleted = await callTool("planka_delete_task", { taskId });
385
+ check("planka_delete_task removes the task", !taskDeleted.isError, taskDeleted.text);
386
+
387
+ // --- labels on the card ---------------------------------------------------
388
+ const swapped = await json("planka_set_card_labels", {
389
+ cardId,
390
+ addLabelIds: [labelB.label.id],
391
+ removeLabelIds: [labelA.label.id],
392
+ });
393
+ check(
394
+ "planka_set_card_labels adds B and removes A (verified by re-reading)",
395
+ swapped?.labelsAdded === 1 &&
396
+ swapped?.labelsRemoved === 1 &&
397
+ swapped?.finalLabelIds?.length === 1 &&
398
+ swapped.finalLabelIds[0] === labelB.label.id,
399
+ swapped
400
+ );
401
+
402
+ state = await rawBoard();
403
+ check(
404
+ "the raw API agrees on which label the card carries",
405
+ state.cardLabels.some(
406
+ (item) => item.cardId === cardId && item.labelId === labelB.label.id
407
+ ) &&
408
+ !state.cardLabels.some(
409
+ (item) => item.cardId === cardId && item.labelId === labelA.label.id
410
+ ),
411
+ state.cardLabels.filter((item) => item.cardId === cardId)
412
+ );
413
+
414
+ // --- scoped reads ---------------------------------------------------------
415
+ const filtered = await json("planka_list_cards", {
416
+ listId: list.id,
417
+ labelFilter: `${PREFIX} B`,
418
+ });
419
+ check(
420
+ "planka_list_cards with labelFilter finds the card",
421
+ filtered?.cards?.some((card) => card.id === cardId),
422
+ filtered
423
+ );
424
+
425
+ const allCards = await json("planka_list_cards", {
426
+ listId: list.id,
427
+ limit: 200,
428
+ withLabels: false,
429
+ });
430
+ const totalInList = allCards?.total ?? 0;
431
+ check(
432
+ "planka_list_cards reports total/returned/hasMore",
433
+ allCards?.total === allCards?.returned &&
434
+ allCards?.hasMore === false &&
435
+ allCards?.nextOffset === undefined,
436
+ allCards
437
+ );
438
+
439
+ const rawListCards = await api.get(`/api/lists/${list.id}/cards`);
440
+ check(
441
+ "the count matches the raw API (or exceeds one page, which is the point)",
442
+ totalInList >= (rawListCards.items?.length ?? 0),
443
+ `mcp total=${totalInList} · raw first page=${rawListCards.items?.length}`
444
+ );
445
+
446
+ check(
447
+ "planka_list_cards omits descriptions by default",
448
+ allCards?.cards?.every((card) => card.description === undefined),
449
+ allCards?.cards?.[0]
450
+ );
451
+
452
+ const page = await json("planka_list_cards", {
453
+ listId: list.id,
454
+ limit: 1,
455
+ withLabels: false,
456
+ });
457
+ check(
458
+ "planka_list_cards clips but says how much was left out",
459
+ page?.returned === 1 &&
460
+ page?.total === totalInList &&
461
+ page?.hasMore === totalInList > 1 &&
462
+ (totalInList > 1 ? page.nextOffset === 1 : true),
463
+ page
464
+ );
465
+
466
+ if (totalInList > 1) {
467
+ const next = await json("planka_list_cards", {
468
+ listId: list.id,
469
+ limit: 1,
470
+ offset: page.nextOffset,
471
+ withLabels: false,
472
+ });
473
+ check(
474
+ "planka_list_cards with an offset returns the next card, not the same one",
475
+ next?.returned === 1 &&
476
+ next.offset === 1 &&
477
+ next.cards[0].id !== page.cards[0].id,
478
+ next
479
+ );
480
+ } else {
481
+ check(
482
+ "planka_list_cards with an offset returns the next card, not the same one",
483
+ true,
484
+ "single-card list: nothing to page"
485
+ );
486
+ }
487
+
488
+ const lists = await json("planka_list_lists", { boardId });
489
+ const targetList = lists?.lists?.find((item) => item.id === list.id);
490
+ check(
491
+ "planka_list_lists counts each column's cards without returning them",
492
+ Boolean(targetList) &&
493
+ !JSON.stringify(lists).includes(CARD_NAME) &&
494
+ lists.lists.every((item) => item.name !== null),
495
+ lists
496
+ );
497
+
498
+ check(
499
+ "planka_list_lists is far cheaper than planka_get_board",
500
+ JSON.stringify(lists).length * 4 < boardText.length,
501
+ `list_lists=${JSON.stringify(lists).length} chars · get_board=${boardText.length} chars`
502
+ );
503
+
504
+ // --- board_summary --------------------------------------------------------
505
+ const summary = await json("planka_board_summary", {
506
+ boardId,
507
+ cardsFrom: [list.id],
508
+ highlightLabel: `${PREFIX} B`,
509
+ });
510
+ const summaryCard = summary?.cards?.find((card) => card.id === cardId);
511
+ check(
512
+ "planka_board_summary returns columns with counts, labels and the requested cards",
513
+ summary?.board?.id === boardId &&
514
+ summary.lists.some((item) => item.id === list.id) &&
515
+ summary.labels.some((item) => item.id === labelB.label.id) &&
516
+ summary.cardsFrom.includes(list.name) &&
517
+ Boolean(summaryCard),
518
+ summary
519
+ );
520
+
521
+ check(
522
+ "planka_board_summary flags the card carrying the highlighted label",
523
+ summaryCard?.highlighted === true &&
524
+ summary?.highlightedCount >= 1 &&
525
+ summary.cards[0].highlighted === true,
526
+ summary?.cards?.slice(0, 3)
527
+ );
528
+
529
+ const summaryOther = await json("planka_board_summary", {
530
+ boardId,
531
+ cardsFrom: [list.id],
532
+ highlightLabel: `${PREFIX} a label that does not exist`,
533
+ });
534
+ check(
535
+ "planka_board_summary highlights nothing when the label is absent",
536
+ summaryOther?.highlightedCount === 0 &&
537
+ summaryOther.cards.every((card) => card.highlighted === undefined),
538
+ summaryOther?.highlightedCount
539
+ );
540
+
541
+ check(
542
+ "planka_board_summary fits in a fraction of planka_get_board",
543
+ JSON.stringify(summary).length * 3 < boardText.length &&
544
+ !JSON.stringify(summary).includes("description"),
545
+ `board_summary=${JSON.stringify(summary).length} chars · get_board=${boardText.length} chars`
546
+ );
547
+
548
+ // --- find_cards -----------------------------------------------------------
549
+ const byLabel = await json("planka_find_cards", {
550
+ boardId,
551
+ labelName: `${PREFIX} B`,
552
+ });
553
+ check(
554
+ "planka_find_cards by label returns exactly the labelled card",
555
+ byLabel?.total === 1 &&
556
+ byLabel.matches[0].id === cardId &&
557
+ byLabel.matches[0].list === list.name,
558
+ byLabel
559
+ );
560
+
561
+ // The description was written by the update above and is NOT in the name:
562
+ // if this matches, the text search really is reading descriptions.
563
+ const byText = await json("planka_find_cards", {
564
+ boardId,
565
+ query: "description updated by the smoke",
566
+ });
567
+ check(
568
+ "planka_find_cards by text matches the description, not just the name",
569
+ byText?.matches?.some((card) => card.id === cardId),
570
+ byText
571
+ );
572
+
573
+ const scoped = await json("planka_find_cards", {
574
+ boardId,
575
+ query: PREFIX,
576
+ listId: list.id,
577
+ });
578
+ check(
579
+ "planka_find_cards with listId stays inside that column",
580
+ scoped?.matches?.length > 0 &&
581
+ scoped.matches.every((card) => card.listId === list.id),
582
+ scoped
583
+ );
584
+
585
+ const noLabel = await json("planka_find_cards", {
586
+ boardId,
587
+ labelName: `${PREFIX} nonexistent label`,
588
+ });
589
+ check(
590
+ "planka_find_cards warns when the label does not exist and lists the ones that do",
591
+ noLabel?.total === 0 &&
592
+ typeof noLabel.warning === "string" &&
593
+ noLabel.warning.includes(`${PREFIX} B`),
594
+ noLabel?.warning
595
+ );
596
+
597
+ const noFilter = await callTool("planka_find_cards", { boardId });
598
+ check(
599
+ "ERROR: an unfiltered find_cards is rejected instead of dumping the board",
600
+ noFilter.isError && noFilter.text.includes("labelName"),
601
+ noFilter.text
602
+ );
603
+
604
+ const moved = await callTool("planka_move_card", {
605
+ cardId,
606
+ listId: list.id,
607
+ position: 1,
608
+ });
609
+ check("planka_move_card repositions inside the list", !moved.isError, moved.text);
610
+
611
+ // --- comments: a 0 has to be a REAL 0 -------------------------------------
612
+ const emptyMcp = await json("planka_get_comments", { cardId });
613
+ const emptyRaw = await api.getComments(cardId);
614
+ check(
615
+ "planka_get_comments on a card with none: 0, and the raw API says 0 too",
616
+ emptyMcp?.commentCount === 0 && (emptyRaw.items?.length ?? 0) === 0,
617
+ `mcp=${emptyMcp?.commentCount} · raw=${emptyRaw.items?.length}`
618
+ );
619
+
620
+ const addedComment = await json("planka_add_comment", {
621
+ cardId,
622
+ text: "smoke: original comment",
623
+ });
624
+ const commentId = addedComment?.comment?.id;
625
+ check("planka_add_comment creates the comment", Boolean(commentId), addedComment);
626
+
627
+ const readMcp = await json("planka_get_comments", { cardId });
628
+ check(
629
+ "planka_get_comments reads back 1 comment with the exact text",
630
+ readMcp?.commentCount === 1 &&
631
+ readMcp.comments[0].text === "smoke: original comment",
632
+ readMcp
633
+ );
634
+
635
+ const readRaw = await api.getComments(cardId);
636
+ check(
637
+ "cross-check against the raw API: same count, same id, same text",
638
+ readRaw.items?.length === readMcp?.commentCount &&
639
+ readRaw.items[0].id === commentId &&
640
+ readRaw.items[0].text === readMcp.comments[0].text,
641
+ readRaw.items?.map((item) => ({ id: item.id, text: item.text }))
642
+ );
643
+
644
+ check(
645
+ "planka_get_comments resolves the author's name",
646
+ Boolean(readMcp?.comments?.[0]?.author),
647
+ readMcp?.comments?.[0]
648
+ );
649
+
650
+ const editedComment = await callTool("planka_manage_comment", {
651
+ action: "update",
652
+ commentId,
653
+ text: "smoke: edited comment",
654
+ });
655
+ check("planka_manage_comment update", !editedComment.isError, editedComment.text);
656
+
657
+ const afterEditMcp = await json("planka_get_comments", { cardId });
658
+ const afterEditRaw = await api.getComments(cardId);
659
+ check(
660
+ "the edit is visible over MCP and over the raw API",
661
+ afterEditMcp?.comments[0].text === "smoke: edited comment" &&
662
+ afterEditRaw.items[0].text === "smoke: edited comment",
663
+ `mcp=${afterEditMcp?.comments[0].text} · raw=${afterEditRaw.items[0]?.text}`
664
+ );
665
+
666
+ const deletedComment = await callTool("planka_manage_comment", {
667
+ action: "delete",
668
+ commentId,
669
+ });
670
+ check("planka_manage_comment delete", !deletedComment.isError, deletedComment.text);
671
+
672
+ const afterDeleteMcp = await json("planka_get_comments", { cardId });
673
+ const afterDeleteRaw = await api.getComments(cardId);
674
+ check(
675
+ "after deleting: 0 over MCP and 0 over the raw API",
676
+ afterDeleteMcp?.commentCount === 0 && (afterDeleteRaw.items?.length ?? 0) === 0,
677
+ `mcp=${afterDeleteMcp?.commentCount} · raw=${afterDeleteRaw.items?.length}`
678
+ );
679
+
680
+ // --- attachments: byte-for-byte roundtrip ---------------------------------
681
+ workDir = mkdtempSync(join(tmpdir(), "planka-mcp-smoke-"));
682
+ const filePath = join(workDir, "smoke-payload.txt");
683
+ const payload =
684
+ "planka-mcp smoke payload\n" +
685
+ "accents: ñáéíóú — çü « » 😀\n" +
686
+ Array.from({ length: 40 }, (_, i) => `line ${i} ${"x".repeat(i)}`).join("\n") +
687
+ "\n";
688
+ writeFileSync(filePath, payload, "utf8");
689
+ const sourceBytes = Buffer.from(payload, "utf8");
690
+ const sourceHash = createHash("sha256").update(sourceBytes).digest("hex");
691
+
692
+ const addedAttachment = await json("planka_add_attachment", {
693
+ cardId,
694
+ filePath,
695
+ name: "smoke-payload.txt",
696
+ });
697
+ const attachmentId = addedAttachment?.attachment?.id;
698
+ if (attachmentId) artifacts.attachments.push(attachmentId);
699
+ check(
700
+ "planka_add_attachment uploads and verifies by re-reading the card",
701
+ addedAttachment?.verified === true && Boolean(attachmentId),
702
+ addedAttachment
703
+ );
704
+
705
+ const listedAttachments = await json("planka_get_attachments", { cardId });
706
+ check(
707
+ "planka_get_attachments lists the attachment with its size and URL",
708
+ listedAttachments?.attachmentCount === 1 &&
709
+ listedAttachments.attachments[0].id === attachmentId &&
710
+ Boolean(listedAttachments.attachments[0].url),
711
+ listedAttachments
712
+ );
713
+
714
+ const downloaded = await api.getBinary(listedAttachments.attachments[0].url);
715
+ const downloadedHash = createHash("sha256").update(downloaded).digest("hex");
716
+ check(
717
+ "byte-identical roundtrip (sha256 uploaded == downloaded)",
718
+ downloadedHash === sourceHash && downloaded.byteLength === sourceBytes.byteLength,
719
+ `uploaded ${sourceHash} (${sourceBytes.byteLength} bytes)\ndownloaded ${downloadedHash} (${downloaded.byteLength} bytes)`
720
+ );
721
+
722
+ const viewed = await callTool("planka_view_attachment", { cardId, attachmentId });
723
+ check(
724
+ "planka_view_attachment returns the content inline, unaltered",
725
+ !viewed.isError && viewed.text.endsWith(payload),
726
+ viewed.text
727
+ );
728
+
729
+ const deletedAttachment = await callTool("planka_delete_attachment", {
730
+ attachmentId,
731
+ });
732
+ if (!deletedAttachment.isError) forget("attachments", attachmentId);
733
+ check(
734
+ "planka_delete_attachment removes the attachment",
735
+ !deletedAttachment.isError,
736
+ deletedAttachment.text
737
+ );
738
+
739
+ const afterAttachmentDelete = await json("planka_get_attachments", { cardId });
740
+ check(
741
+ "the card is back to 0 attachments",
742
+ afterAttachmentDelete?.attachmentCount === 0,
743
+ afterAttachmentDelete
744
+ );
745
+
746
+ // --- board lists ----------------------------------------------------------
747
+ const tempList = await json("planka_manage_lists", {
748
+ action: "create",
749
+ boardId,
750
+ name: `${PREFIX} temporary list`,
751
+ });
752
+ if (tempList?.list?.id) artifacts.lists.push(tempList.list.id);
753
+ check("planka_manage_lists create", Boolean(tempList?.list?.id), tempList);
754
+
755
+ const renamedList = await json("planka_manage_lists", {
756
+ action: "update",
757
+ listId: tempList.list.id,
758
+ name: `${PREFIX} renamed list`,
759
+ });
760
+ check(
761
+ "planka_manage_lists update renames",
762
+ renamedList?.list?.name === `${PREFIX} renamed list`,
763
+ renamedList
764
+ );
765
+
766
+ const deletedList = await callTool("planka_manage_lists", {
767
+ action: "delete",
768
+ listId: tempList.list.id,
769
+ });
770
+ if (!deletedList.isError) forget("lists", tempList.list.id);
771
+ check("planka_manage_lists delete", !deletedList.isError, deletedList.text);
772
+
773
+ // --- actionable error paths ----------------------------------------------
774
+ const errMissing = await callTool("planka_add_comment", { cardId });
775
+ check(
776
+ "ERROR: a missing required field names the tool and the field",
777
+ errMissing.isError &&
778
+ errMissing.text.includes("text") &&
779
+ errMissing.text.includes("planka_add_comment"),
780
+ errMissing.text
781
+ );
782
+
783
+ const errEmptyId = await callTool("planka_get_card", { cardId: "" });
784
+ check(
785
+ "ERROR: an empty id is rejected with an actionable message",
786
+ errEmptyId.isError && errEmptyId.text.includes("cardId"),
787
+ errEmptyId.text
788
+ );
789
+
790
+ const errUnknown = await callTool("planka_get_cardz", { cardId: "x" });
791
+ check(
792
+ "ERROR: an unknown tool lists the available ones",
793
+ errUnknown.isError && errUnknown.text.includes("Unknown tool"),
794
+ errUnknown.text
795
+ );
796
+
797
+ const errNoop = await callTool("planka_set_card_labels", { cardId });
798
+ check(
799
+ "ERROR: set_card_labels with no labels does not fake success",
800
+ errNoop.isError && errNoop.text.toLowerCase().includes("nothing to do"),
801
+ errNoop.text
802
+ );
803
+
804
+ const errBadType = await callTool("planka_manage_lists", {
805
+ action: "create",
806
+ boardId,
807
+ name: `${PREFIX} should not exist`,
808
+ position: "not-a-number",
809
+ });
810
+ check(
811
+ "ERROR: a wrong type is rejected before touching Planka",
812
+ errBadType.isError && errBadType.text.includes("position"),
813
+ errBadType.text
814
+ );
815
+
816
+ const errAttachment = await callTool("planka_view_attachment", {
817
+ cardId,
818
+ attachmentId: "999999999999999999",
819
+ });
820
+ check(
821
+ "ERROR: a missing attachment says what is available",
822
+ errAttachment.isError && errAttachment.text.includes("not found"),
823
+ errAttachment.text
824
+ );
825
+
826
+ // --- a projectId handed to a boardId --------------------------------------
827
+ const structure = await json("planka_get_structure", {});
828
+ const projectId = structure?.[0]?.project?.id ?? null;
829
+ if (projectId) {
830
+ const asBoard = await callTool("planka_get_board", { boardId: projectId });
831
+ // Two valid outcomes: a single-board project resolves itself and returns
832
+ // the board; a multi-board one has to say it is a projectId and list them.
833
+ check(
834
+ "a projectId passed as boardId resolves or explains itself, never a mute 404",
835
+ !asBoard.isError ||
836
+ (asBoard.text.includes("projectId") && asBoard.text.includes("boards")),
837
+ asBoard.text
838
+ );
839
+ } else {
840
+ check(
841
+ "a projectId passed as boardId resolves or explains itself, never a mute 404",
842
+ false,
843
+ "could not resolve a projectId from planka_get_structure"
844
+ );
845
+ }
846
+
847
+ const errNoSuchBoard = await callTool("planka_get_board", {
848
+ boardId: "999999999999999999",
849
+ });
850
+ check(
851
+ "an unknown boardId says it is not a projectId either and lists the real boards",
852
+ errNoSuchBoard.isError &&
853
+ errNoSuchBoard.text.includes("not a projectId") &&
854
+ errNoSuchBoard.text.includes(boardId),
855
+ errNoSuchBoard.text
856
+ );
857
+
858
+ // --- teardown, verified ---------------------------------------------------
859
+ const deletedCard = await callTool("planka_delete_card", { cardId });
860
+ if (!deletedCard.isError) forget("cards", cardId);
861
+ check("planka_delete_card deletes the card", !deletedCard.isError, deletedCard.text);
862
+
863
+ state = await rawBoard();
864
+ check(
865
+ "the raw API confirms the card is gone",
866
+ !state.cards.some((item) => item.id === cardId),
867
+ cardId
868
+ );
869
+
870
+ for (const labelId of [labelA.label.id, labelB.label.id]) {
871
+ const deletedLabel = await callTool("planka_manage_labels", {
872
+ action: "delete",
873
+ labelId,
874
+ });
875
+ if (!deletedLabel.isError) forget("labels", labelId);
876
+ check(`planka_manage_labels delete (${labelId})`, !deletedLabel.isError, deletedLabel.text);
877
+ }
878
+
879
+ state = await rawBoard();
880
+ const leftovers = [
881
+ ...state.cards.filter((item) => item.name?.startsWith(PREFIX)),
882
+ ...state.lists.filter((item) => item.name?.startsWith(PREFIX)),
883
+ ...state.labels.filter((item) => item.name?.startsWith(PREFIX)),
884
+ ];
885
+ check(
886
+ "the board carries no smoke artifacts",
887
+ leftovers.length === 0,
888
+ leftovers.map((item) => `${item.id} ${item.name}`).join("\n")
889
+ );
890
+ }
891
+
892
+ // ---------------------------------------------------------------------------
893
+ // Main
894
+ // ---------------------------------------------------------------------------
895
+
896
+ let crashed = null;
897
+ try {
898
+ await client.connect(transport);
899
+ console.log(`Planka: ${api.baseUrl} (${process.env.PLANKA_AGENT_EMAIL})\n`);
900
+ await run();
901
+ } catch (error) {
902
+ crashed = error instanceof Error ? error : new Error(String(error));
903
+ } finally {
904
+ await cleanup();
905
+ await client.close().catch(() => {});
906
+ }
907
+
908
+ console.log("\n" + "=".repeat(60));
909
+ if (crashed) {
910
+ console.log(`SMOKE_FAILED: aborted — ${crashed.message}`);
911
+ if (process.env.VERBOSE) console.error(crashed);
912
+ }
913
+ console.log(`checks: ${passed} PASS · ${failed} FAIL · ${passed + failed} total`);
914
+ const exitCode = crashed || failed > 0 ? 1 : 0;
915
+ console.log(
916
+ exitCode === 0
917
+ ? `SMOKE_OK board=${boardId} tools=${EXPECTED_TOOL_COUNT} raw_api_verified=true`
918
+ : "SMOKE_FAILED"
919
+ );
920
+ process.exit(exitCode);